Merge main into local_directory work (MUL-2618)

Resolve conflicts in server/internal/daemon/daemon.go and
server/internal/daemon/execenv/execenv.go by:

- Preserving main's removal of the per-agent skills_local toggle
  (744b4741): drop SkillsLocal from PrepareParams + ReuseParams in
  execenv.go and drop the envSkillsLocal helper in daemon.runTask.
- Preserving this PR's LocalWorkDir / LocalDirectory / GC protection /
  local path lock state machine for the local_directory flow.

Regenerate sqlc to drop the now-stale skills_local column from the
agent.sql.go scanners.

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Multica J
2026-05-26 19:58:16 +08:00
co-authored by multica-agent
111 changed files with 8484 additions and 2971 deletions
+55
View File
@@ -298,6 +298,8 @@ To roll back if an upgrade goes sideways:
helm -n multica rollback multica
```
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** Same migration guard as the Docker path — see [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule). Run the backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations.
### Tearing down
```bash
@@ -310,6 +312,57 @@ kubectl delete namespace multica
---
## Usage Dashboard Rollup (Required)
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table rather than directly from `task_usage`. Raw `task_usage` rows are written by the backend on every task, but the dashboard only sees data after `rollup_task_usage_hourly()` runs and aggregates them into `task_usage_hourly`.
**The bundled `pgvector/pgvector:pg17` image does NOT include `pg_cron`.** If nothing schedules the rollup, the dashboard will stay at zero forever even though `task_usage` is populated. You have three supported options — pick one before relying on the dashboard.
> **Upgrading from `v0.3.4` to `v0.3.5+`** with existing `task_usage` history: migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: …`. Run `backfill_task_usage_hourly` first (Option C below), then re-run the upgrade. **Fresh installs** are exempted by that guard and migrate cleanly — but the dashboard will still stay at zero until you pick Option A or Option B.
### Option A — External cron / systemd-timer (simplest)
Schedule a 5-minute job that calls `rollup_task_usage_hourly()`. It is idempotent and watermark-driven, so a missed tick catches up on the next run.
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Or as a systemd timer + service if you prefer that surface. The function returns the number of (upserted + deleted-empty) rows; it's safe to call concurrently with itself (an advisory lock makes overlapping runs no-op) and safe to call alongside `backfill_task_usage_hourly`.
### Option B — Swap Postgres for an image that ships `pg_cron`
If you'd rather have Postgres schedule itself, replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that bundles both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or your own build of `pgvector/pgvector` with `pg_cron` added and `shared_preload_libraries=pg_cron` set on the server). Then, once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
`shared_preload_libraries` requires a Postgres restart to take effect — set it in `postgresql.conf` (or via the image's documented mechanism) before bringing the container up.
### Option C — Backfill history first, then schedule
If you're upgrading from `v0.3.4 → v0.3.5+` and already have `task_usage` rows (or you just want the dashboard to show historical data on a fresh install that you've been running for a while), run the bundled backfill command once before scheduling the rollup:
```bash
# Backfills task_usage_hourly from all historical task_usage rows and stamps
# the rollup watermark. Idempotent — safe to re-run.
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
On a database with years of data this can scan tens of millions of rows; `--sleep-between-slices=2s` throttles the read pressure. Use `--months-back N` (plus `--force-partial`) if you only want the last N months. Once it finishes, set up Option A or Option B so new buckets keep flowing.
After upgrading, re-run `migrate up` (or restart the backend container — migrations run automatically on startup) to apply migration `103` cleanly.
## Stopping Services
If you installed via the install script:
@@ -350,6 +403,8 @@ docker compose -f docker-compose.selfhost.yml up -d
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. Run `backfill_task_usage_hourly` first, then re-run the upgrade. Full instructions in [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule).
---
## Manual Docker Compose Setup
+105
View File
@@ -166,6 +166,111 @@ The Docker Compose setup runs migrations automatically. If you need to run them
cd server && go run ./cmd/migrate up
```
## Usage Dashboard Rollup
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. The function is **not** scheduled out of the box on the default self-host stack: the bundled `pgvector/pgvector:pg17` image ships without `pg_cron`, and the backend does not run the rollup in-process either. Until something calls it on a schedule, raw `task_usage` rows will keep arriving while the dashboard stays at zero.
Pick one of the supported paths:
### Option A — External cron / systemd-timer
The simplest path. Schedule `SELECT rollup_task_usage_hourly()` every five minutes from any out-of-band timer (host crontab, systemd timer, sidecar container, Kubernetes CronJob). It is idempotent and watermark-driven — overlapping runs are no-ops on an internal advisory lock, and a missed tick catches up on the next run.
Docker Compose:
```bash
# /etc/cron.d/multica-rollup
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Kubernetes (one-off `CronJob`):
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: multica-usage-rollup
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: psql
image: postgres:17-alpine
command:
- psql
- "$(DATABASE_URL)"
- -c
- "SELECT rollup_task_usage_hourly();"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: multica-secrets
key: DATABASE_URL
```
### Option B — Postgres with `pg_cron`
If you'd rather have Postgres schedule itself, swap the bundled image for one that ships both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or a custom build of `pgvector/pgvector` with `pg_cron` added). `pg_cron` requires `shared_preload_libraries=pg_cron` in `postgresql.conf`, which only takes effect on Postgres restart — set it before bringing the container up.
Then register the job once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
`pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, point `pg_cron` at it via that GUC or run `cron.schedule_in_database(...)` instead.
### Option C — Backfill historical data first
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was scheduled — most commonly when upgrading from `v0.3.4` to `v0.3.5+`, or on a fresh install that has been collecting usage for a while — run `backfill_task_usage_hourly` once to seed historical buckets, then set up Option A or Option B for ongoing rollups.
```bash
# Docker Compose
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
# Kubernetes
kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the cron path uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with an already-scheduled rollup. Flags:
| Flag | Description |
|---|---|
| `--sleep-between-slices` | Pause between monthly slices to throttle read pressure on busy databases (e.g. `2s`). Recommended on production DBs with years of history. |
| `--months-back N` | Only backfill the last N months. **Requires `--force-partial`** because the watermark still advances past the skipped older buckets — those are permanently abandoned. |
| `--dry-run` | Log slices that would be processed without writing anything. |
After backfill completes, the rollup-state watermark is stamped to `now() - 5 minutes`, so the first scheduled tick after backfill does not redo history.
### `v0.3.4 → v0.3.5+` upgrade order
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. If you run `migrate up` straight through on a database with existing `task_usage` rows, it aborts with:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
Recovery is straightforward: run `backfill_task_usage_hourly` (Option C above), then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and migrations succeed, but the dashboard will still stay at zero until you set up Option A or Option B.
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:
@@ -133,6 +133,18 @@ Alternatively, configure step by step: `multica config set server_url http://loc
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent
## Usage Dashboard Rollup (Required)
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` image does **not** include `pg_cron`, and the backend doesn't run the rollup in-process either — until you schedule it yourself, the dashboard will stay at zero even though `task_usage` is populated.
Pick one supported path before relying on the Usage / Runtime dashboard:
- **External cron / systemd-timer / Kubernetes `CronJob`**: schedule `SELECT rollup_task_usage_hourly()` every 5 minutes. Idempotent, watermark-driven — overlapping or skipped ticks are safe.
- **Postgres with `pg_cron`**: swap the bundled Postgres image for one that ships `pg_cron`, set `shared_preload_libraries=pg_cron`, then `SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', 'SELECT rollup_task_usage_hourly()')` once.
- **Backfill historical data**: required on the `v0.3.4 → v0.3.5+` upgrade path when the database already has `task_usage` rows — migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run `./backfill_task_usage_hourly --sleep-between-slices=2s` inside the backend container, then re-run the upgrade and configure one of the schedules above.
Full reference (Compose + Kubernetes templates, flag descriptions, upgrade order) lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
## Stopping Services
```bash
+1 -1
View File
@@ -40,7 +40,7 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
Multica supports two layers of skills:
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`). For Claude Code and Codex, host-machine skills under `~/.claude/skills/` and `~/.codex/skills/` are **merged by default** for each agent (so existing personal workflows keep working) — toggle the per-agent **Allow host-machine Skills** switch off to isolate a shared agent against a broken local skill on one operator's machine (GitHub #3052). Other runtimes ignore this setting — the daemon does not actively manage user-level skill discovery for them today.
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
@@ -159,6 +159,45 @@ After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` i
Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-create-an-agent).
## 7. Schedule the usage rollup (required for the Usage dashboard)
<Callout type="warning">
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` Postgres image **does not include `pg_cron`**, and the backend does not run the rollup in-process either. If nothing schedules `rollup_task_usage_hourly()`, raw `task_usage` rows keep arriving while the dashboard stays at zero forever.
</Callout>
Pick one of the supported options — only one is needed.
**Option A — External cron / systemd-timer (simplest).** Run the rollup every 5 minutes from any out-of-band scheduler. It's idempotent and watermark-driven, so missed ticks catch up:
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**Option B — Swap Postgres for an image that ships `pg_cron`.** Replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that has both `pgvector` and `pg_cron` (`supabase/postgres`, or a custom build), set `shared_preload_libraries=pg_cron`, restart, then register the job once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
**Option C — Backfill history first (upgrade path).** If you're upgrading from `v0.3.4 → v0.3.5+` and have existing `task_usage` rows, migration `103` will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run the bundled backfill once, then set up Option A or B:
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s` throttles read pressure on a busy DB. After it finishes, restart the backend container (migrations run on startup) and the upgrade completes.
Full reference — including the Kubernetes `CronJob` template and the upgrade order — lives in the repo's [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
## Kubernetes deployment (alternative)
If you already run a Kubernetes cluster, the repo also ships a Helm chart at `deploy/helm/multica/`. It's the equivalent of `make selfhost` for k8s — same backend image, frontend image, and `pgvector/pgvector:pg17` Postgres, packaged as Deployments / Services / Ingresses with one `ConfigMap` rendered from `values.yaml`. Authored against k3s + Traefik + `local-path` and should work on any cluster with an Ingress controller and a default `ReadWriteOnce` StorageClass.
@@ -211,6 +250,8 @@ The full reference — three login modes, the `backend` ExternalName workaround
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
- **Usage / Runtime dashboard stays at zero**: `rollup_task_usage_hourly()` isn't being scheduled — see [Step 7](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard) above and [Troubleshooting → Usage dashboard shows zero](/troubleshooting#usage-dashboard-stays-at-zero)
- **`migrate up` fails with `refusing to drop legacy daily rollups`**: upgrade-path guard from `v0.3.4 → v0.3.5+`. Run `backfill_task_usage_hourly` first — see [Step 7 → Option C](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)
## Next steps
@@ -158,6 +158,45 @@ multica.example.com {
流程和 Cloud 一样——见 [Cloud 快速上手 → 5-6 步](/cloud-quickstart#5-创建智能体)。
## 7. 调度用量汇总任务(Usage Dashboard 必需)
<Callout type="warning">
Usage / Runtime 看板读的是派生表 `task_usage_hourly`,需要 `rollup_task_usage_hourly()` 周期性运行才能填充。**默认的 `pgvector/pgvector:pg17` 镜像不带 `pg_cron`**,后端进程内部也不会跑这个 rollup——什么都没调度的话,原始 `task_usage` 行会继续写入,但 dashboard 会一直停在 0,不会报错。
</Callout>
三种支持路径,三选一即可。
**Option A —— 外部 cron / systemd-timer(最简单)。** 在任意外部调度器上每 5 分钟跑一次 rollup。函数是幂等的、按 watermark 推进,丢一两个 tick 下次能补上:
```bash
# /etc/cron.d/multica-rollup —— 每 5 分钟跑一次
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**Option B —— 换成自带 `pg_cron` 的 Postgres 镜像。** 把 `docker-compose.selfhost.yml` 里的 `pgvector/pgvector:pg17` 换成同时带 `pgvector` 和 `pg_cron` 的镜像(比如 `supabase/postgres`,或自己 build 一份),把 `shared_preload_libraries=pg_cron` 配上、重启 Postgres,然后注册一次任务:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
**Option C —— 先回填历史(升级路径)。** 如果你是从 `v0.3.4` 升级到 `v0.3.5+` 且数据库里已经有 `task_usage` 行,migration `103` 会以 `refusing to drop legacy daily rollups: ...` 报错并中止 `migrate up`,直到 hourly 表被 seed 过。先跑一次内置的 backfill 命令,然后再配 Option A 或 Option B 让新数据持续流进来:
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s` 用来在繁忙的数据库上限制读压力。回填跑完后重启后端容器(migration 在启动时自动跑),升级就能继续。
完整参考(含 Kubernetes `CronJob` 模板和升级顺序)见仓库的 [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup)。
## Kubernetes 部署(替代方案)
如果你已经在跑 Kubernetes 集群,仓库里也带了一个 Helm chart,路径 `deploy/helm/multica/`。它就是 k8s 版的 `make selfhost`——一样的 backend 镜像、frontend 镜像、`pgvector/pgvector:pg17` Postgres,封装成 Deployment / Service / Ingress,再加上一个由 `values.yaml` 渲染出来的 `ConfigMap`。这套 chart 是按照 k3s + Traefik + `local-path` 写的,集群里只要有 Ingress controller 和默认的 `ReadWriteOnce` StorageClass 就能跑,其他类型的集群稍微改一改也能用。
@@ -210,6 +249,8 @@ multica setup self-host \
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
- **验证码收不到**:没配任何邮件后端(Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
- **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上)
- **Usage / Runtime 看板一直是 0**:没人调度 `rollup_task_usage_hourly()` —— 见上面的 [第 7 步](#7-调度用量汇总任务usage-dashboard-必需) 和 [故障排查 → Usage 看板一直是 0](/troubleshooting#usage-看板一直是-0)
- **`migrate up` 报 `refusing to drop legacy daily rollups`**:`v0.3.4 → v0.3.5+` 升级路径的 fail-closed guard。先跑 `backfill_task_usage_hourly` —— 见 [第 7 步 → Option C](#7-调度用量汇总任务usage-dashboard-必需)
## 下一步
-18
View File
@@ -16,24 +16,6 @@ Multica supports two skill sources:
Most of the time you want **workspace skills**: import once, every teammate's agent can use it. Local skills are a fit when you want to test locally first, or when the content involves sensitive local material.
### Per-agent host-skill merge toggle
By default Multica **merges** the host machine's user-global skill directory into the agent (so existing personal workflows that rely on locally installed Claude skills keep working out of the box). You can flip the toggle to **ignore** when a shared agent should be hardened against a broken local skill on one operator's machine — that scenario crashes the Claude CLI before it ever reads a prompt (silent `broken pipe` exits, see GitHub #3052).
Each agent carries a `skills_local` switch you can change from the **Create Agent** dialog or the agent's **Skills** tab:
- **On — Merge (default)**: the runtime inherits the full `~/.claude/` directory, including its `skills/`. Matches the pre-MUL-2603 behavior — recommended for personal agents that intentionally rely on locally installed skills.
- **Off — Ignore**: the runtime launches against a per-task config directory that mirrors the host's effective Claude config dir *except* for `skills/`. The source is resolved with precedence agent `custom_env` `CLAUDE_CONFIG_DIR` > parent process `CLAUDE_CONFIG_DIR` > `~/.claude/`, so an operator who pinned Claude at a managed install keeps the right credentials and settings. Login credentials (`.credentials.json`), global settings, plugins, and agents pass through, so Claude Code authentication keeps working — only the user-global skills directory is hidden from the CLI. On Windows hosts without Developer Mode the mirror falls back to directory junctions and hardlink/copy for files, so the passthrough still works without elevated privileges. Recommended for any shared agent where host-skill safety matters.
Workspace skills under `{workDir}/.claude/skills/` (and the equivalent workdir-scoped paths for other runtimes) are loaded regardless of the toggle — this switch only governs the host's user-global skills directory.
**Which runtimes the toggle covers today:** Claude Code and Codex are the two runtimes where the daemon actively manages user-skill discovery, so they are the two that honor the toggle:
- **Claude Code** — when set to **Off**, `CLAUDE_CONFIG_DIR` points at a per-task scratch dir that mirrors the host's effective Claude config dir *except* for `skills/` (see above).
- **Codex** — Codex always runs inside a per-task `CODEX_HOME`. When set to **On (default)** the daemon seeds that `CODEX_HOME` with user-installed skills from the shared `~/.codex/skills/`; when set to **Off** the seed step is skipped, so the Codex CLI sees only workspace skills.
For every other runtime (Hermes / Kimi / Kiro / Copilot / Cursor / Gemini / Pi / OpenCode / OpenClaw) the daemon leaves the user's `$HOME` untouched and does not actively manage user-level skill discovery — whether that runtime reads any user-level skill directory is entirely up to its own CLI. **The toggle is a no-op for these runtimes today**, regardless of where it is set; a future change can extend per-runtime isolation as upstream CLIs ship the necessary knobs.
## Importing a skill
Workspace skills come from four sources:
-18
View File
@@ -16,24 +16,6 @@ Multica 支持两种 Skill 来源:
大多数情况用**工作区 Skill**:导入一次,团队所有成员的智能体都能用。本机 Skill 适合先在本地测试、或涉及敏感本地内容的场景。
### 按智能体维度的"合并本机 Skill"开关
默认情况下 Multica **会**把本机用户级 skill 目录合并进智能体(保持 MUL-2603 之前的"继承本机"行为,避免误伤依赖本机 skill 的个人智能体)。如果某个共享智能体需要防御"某台机器上有问题的本机 skill"——这种情况会让 Claude CLI 还没读到 prompt 就崩(典型表现是 `broken pipe`,参见 GitHub #3052)——你可以把开关切到 ignore。
每个智能体都带有一个 `skills_local` 开关,可在 **创建 Agent** 弹窗或智能体的 **Skills** 标签页里切换:
- **开启 —— Merge(默认)**:运行时直接继承完整的 `~/.claude/`,包括 `skills/`。沿用 MUL-2603 之前的行为,适合个人智能体或明确依赖本机 skill 的场景。
- **关闭 —— Ignore**:运行时使用按任务隔离的配置目录,目录里通过软链镜像了主机实际生效的 Claude 配置目录中除 `skills/` 之外的全部内容。源目录的解析顺序为:agent `custom_env` 中的 `CLAUDE_CONFIG_DIR` > 父进程 `CLAUDE_CONFIG_DIR` > `~/.claude/`,因此把 Claude 固定到托管安装目录的运维者仍能拿到正确的凭据和设置。登录凭据(`.credentials.json`)、全局设置、插件、本地 agent 仍会被 CLI 看到,Claude Code 登录态不受影响,只屏蔽用户级 skill 目录。在未开启开发者模式的 Windows 主机上,软链失败时会自动回退为目录联结(junction)以及文件的硬链接/拷贝,保证无管理员权限时镜像仍然可用。**推荐在需要防御本机 skill 风险的共享智能体上启用**。
无论开关如何,工作区目录下的 `{workDir}/.claude/skills/`(以及其他 runtime 在工作目录下对应的路径)都会照常加载——这个开关只控制本机用户级 skill 目录。
**目前哪些运行时实际受开关影响:** 守护进程主动管理用户级 skill 发现的运行时是 Claude Code 和 Codex,所以开关对它们都生效:
- **Claude Code** —— 关闭时把 `CLAUDE_CONFIG_DIR` 指向按任务隔离的目录,里面镜像了主机有效的 Claude 配置目录中除 `skills/` 之外的内容(见上)。
- **Codex** —— Codex 一向运行在按任务隔离的 `CODEX_HOME` 下。开启(默认)时守护进程会把共享的 `~/.codex/skills/` seed 到该目录;关闭时跳过这一步,Codex CLI 只能看到工作区 skill。
对其它运行时(Hermes / Kimi / Kiro / Copilot / Cursor / Gemini / Pi / OpenCode / OpenClaw),守护进程既不改写用户的 `$HOME`,也不主动管理用户级 skill 发现——这些运行时是否读取任何用户级 skill 目录,完全由各自的 CLI 决定。**无论该开关如何设置,对这些运行时都是 no-op**;待上游 CLI 提供必要的隔离开关后,可以再补齐。
## 导入 Skill
工作区 Skill 有四种来源:
+70 -1
View File
@@ -1,6 +1,6 @@
---
title: Troubleshooting
description: The top 7 common issues when self-hosting Multica — symptoms, causes, how to diagnose, how to fix.
description: Common issues when self-hosting Multica — symptoms, causes, how to diagnose, how to fix.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -132,6 +132,75 @@ Check your inbox (including spam) for the real verification code.
- 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))
## Usage dashboard stays at zero
**Symptom**: agents complete tasks, raw token usage is written to the database, but **Settings → Usage** and **Settings → Runtime** show 0 input / output / cost across the board. This is silent — there is no error in the backend logs.
**Likely causes**:
1. **`rollup_task_usage_hourly()` is never scheduled** — the Usage / Runtime dashboards read from the derived `task_usage_hourly` table, which is populated by that function. The bundled `pgvector/pgvector:pg17` image does not include `pg_cron`, and the backend does not run the rollup in-process either. On a fresh self-host install with no external scheduler, this is the default state.
2. **`pg_cron` is installed but pointing at the wrong database** — `pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, the scheduled job never sees `rollup_task_usage_hourly()`.
3. **The scheduler is running but the rollup is silently erroring** — e.g. wrong DB role / search_path inside the cron entry.
**How to diagnose**:
```sql
-- Confirm raw events exist but the hourly table is empty.
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- Confirm pg_cron is (or isn't) available.
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- If pg_cron is installed, check the schedule + last run.
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- Watermark — if this is 1970-01-01, the rollup has never run.
SELECT watermark_at FROM task_usage_hourly_rollup_state;
```
**How to fix**:
- Call the rollup once by hand to confirm it works: `SELECT rollup_task_usage_hourly();` — refresh the dashboard; if numbers appear, the only missing piece is a scheduler.
- Pick one of the supported paths from [Self-host quickstart → Schedule the usage rollup](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard): external cron / systemd-timer / Kubernetes CronJob, or swap Postgres for an image with `pg_cron`.
- If you already have history that pre-dates the schedule, run `backfill_task_usage_hourly` inside the backend container to seed buckets before the watermark.
## Migration `103` fails with `refusing to drop legacy daily rollups`
**Symptom**: upgrading from `v0.3.4` to `v0.3.5+`, the backend container fails to start (or `migrate up` aborts) with:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
**Likely cause**: this is migration `103`'s fail-closed guard. It refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up with raw `task_usage`. The guard fires whenever existing rows are present and the rollup watermark still sits at the epoch — i.e. nothing has rolled history into the hourly table yet.
**How to fix**:
1. Run the backfill against the same database (idempotent, safe to interrupt, safe to re-run):
```bash
# Docker Compose
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
# Kubernetes
kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
2. Re-run the upgrade — restarting the backend container is enough, migrations run on startup. The guard now sees a current watermark and lets `103` apply.
3. Set up an ongoing rollup schedule (cron / `pg_cron`) so the watermark keeps advancing — see [Self-host quickstart → Schedule the usage rollup](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard).
`--sleep-between-slices=2s` is a polite default on production databases with years of history. Use `--months-back N --force-partial` if you only want to keep the last N months and are willing to permanently abandon older buckets.
## Port conflicts
**Symptom**: `multica server` or `multica daemon start` fails with `address already in use`.
+70 -1
View File
@@ -1,6 +1,6 @@
---
title: 故障排查
description: self-host Multica 遇到的 Top 7 常见问题——症状、原因、怎么查、怎么修。
description: self-host Multica 常见问题——症状、原因、怎么查、怎么修。
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -132,6 +132,75 @@ docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
- 生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,配好 Resend 后使用真实验证码
- 本地开发或内网测试可以从 server 日志抄生成的验证码;如果需要 `888888`,设置 `APP_ENV=development` 和 `MULTICA_DEV_VERIFICATION_CODE=888888`。不要在公网实例启用固定验证码(详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码))
## Usage 看板一直是 0
**症状**:agent 执行完任务、原始的 token 用量已经写入数据库,但 **Settings → Usage** 和 **Settings → Runtime** 上输入 / 输出 / 成本全部显示 0。**没有任何报错**——这是静默故障。
**可能原因**:
1. **`rollup_task_usage_hourly()` 没人调度** —— Usage / Runtime 看板读的是派生表 `task_usage_hourly`,这张表必须靠 `rollup_task_usage_hourly()` 周期性填充。默认的 `pgvector/pgvector:pg17` 镜像不带 `pg_cron`,后端进程内部也不会跑 rollup。如果你是新装的自部署、没配过外部调度器,默认就是这种状态。
2. **`pg_cron` 装了但指向了错的库** —— `pg_cron.database_name` 默认是 `postgres`;如果你的 Multica 数据库名不是 `postgres`,调度任务根本看不到 `rollup_task_usage_hourly()`。
3. **调度跑了,但 rollup 静默报错** —— 比如 cron entry 里 DB role / search_path 不对。
**怎么查**:
```sql
-- 确认原始数据有、hourly 表是空的
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- 看 pg_cron 装没装、有没有加载
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- 如果 pg_cron 装了,看调度和最近一次运行
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- watermark —— 如果还是 1970-01-01,说明 rollup 从来没跑过
SELECT watermark_at FROM task_usage_hourly_rollup_state;
```
**怎么修**:
- 手动跑一次确认函数本身没问题:`SELECT rollup_task_usage_hourly();` —— 刷新看板;如果数字出来了,缺的就只是调度器。
- 从 [Self-host 快速上手 → 调度用量汇总任务](/self-host-quickstart#7-调度用量汇总任务usage-dashboard-必需) 里挑一种调度方式:外部 cron / systemd-timer / Kubernetes CronJob,或者换成带 `pg_cron` 的 Postgres 镜像。
- 如果调度配好之前数据库已经有一段历史,先在后端容器里跑 `backfill_task_usage_hourly` 把 watermark 之前的桶补出来。
## migration `103` 报 `refusing to drop legacy daily rollups`
**症状**:从 `v0.3.4` 升级到 `v0.3.5+` 时,后端容器起不来(或 `migrate up` 中止),错误信息:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
**可能原因**:这是 migration `103` 的 fail-closed guard。它要求 `task_usage_hourly` 已经追平了原始的 `task_usage` 之后,才允许丢掉旧的 daily rollup。只要数据库里有历史数据、且 rollup watermark 还停在 epoch(说明还没把历史回填进 hourly 表),这条 guard 就会拦住。
**怎么修**:
1. 对同一个数据库跑一次 backfill(幂等,可以打断,可以重试):
```bash
# Docker Compose
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
# Kubernetes
kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
2. 重新跑升级 —— 重启 backend 容器即可,启动时会自动跑 migration。Guard 看到新的 watermark,`103` 就会通过。
3. 同时配上持续的 rollup 调度,保证 watermark 持续推进 —— 见 [Self-host 快速上手 → 调度用量汇总任务](/self-host-quickstart#7-调度用量汇总任务usage-dashboard-必需)。
`--sleep-between-slices=2s` 在有多年历史的生产库上是个比较克制的默认值。如果你只想保留最近 N 个月、可以接受永久丢掉更老的桶,用 `--months-back N --force-partial`。
## 端口冲突
**症状**:`multica server` 或 `multica daemon start` 启动失败,报 `address already in use`。
+23 -1
View File
@@ -293,7 +293,29 @@ export function createEnDict(allowSignup: boolean): LandingDict {
},
entries: [
{
version: "0.3.7",
version: "0.3.9",
date: "2026-05-26",
title: "Swimlanes & More Predictable Issues",
changes: [],
features: [
"Issues now support a swimlane view, letting teams review parent work and status columns together when large projects have many subtasks",
"Issue lists now support drag-and-drop ordering, sticky group headers, and stable ordering when more results load",
],
improvements: [
"The CLI now shows the local service version in status output and uses cleaner status fields",
"Self-hosting guidance now explains the hourly usage rollup required for accurate usage views, with updated English and Chinese troubleshooting notes",
"Agent skill settings were simplified before release so unsupported local-skill controls no longer appear in the product",
"Dependency checks are stricter, helping missing packages surface earlier before builds or desktop runs",
],
fixes: [
"GitHub PRs now finish linked Issues only when the PR clearly says it closes them, so reference-only links no longer close work by accident",
"Parent/child Issue automation waits for dependency checks before starting queued follow-up work, and agent-driven status changes trigger the right assignee",
"Issue threads and list pages keep their order when loading more content, and board drag positions stay stable",
"Chat resize behavior, board-card assignee rows, and XML parsing security received smaller stability fixes",
],
},
{
version: "0.3.8",
date: "2026-05-25",
title: "Multica for iOS, Helm Self-Hosting & Smoother Collaboration",
changes: [],
+23 -1
View File
@@ -293,7 +293,29 @@ export function createZhDict(allowSignup: boolean): LandingDict {
},
entries: [
{
version: "0.3.7",
version: "0.3.9",
date: "2026-05-26",
title: "泳道视图与更稳定的 Issue 体验",
changes: [],
features: [
"Issue 现在支持泳道视图,团队可以按父 Issue 和状态一起查看工作,拆分后的子任务更容易归类和追踪",
"Issue 列表支持拖拽调整顺序,分组标题会在滚动时保持可见,加载更多内容后也能保持正确排序",
],
improvements: [
"命令行里的本机运行服务状态现在会显示版本,并用更清晰的字段展示运行状态",
"自托管文档补充了用量统计所需的小时汇总配置,排查步骤也覆盖中英文文档",
"智能体技能设置在发布前收紧,产品里不再显示运行环境不支持的本机技能开关",
"依赖检查更严格,前端包会更早发现缺失依赖,减少打包和桌面端运行时的意外",
],
fixes: [
"GitHub PR 只有写明关闭意图时才会自动完成关联 Issue,普通引用链接不会误关 Issue",
"父子 Issue 的自动推进更稳:依赖未满足时不会提前启动后续任务,智能体把待处理任务切到进行中时也会正确触发负责人",
"Issue 讨论串和列表加载更多内容时会保持正确顺序,看板拖拽后的位置也更稳定",
"聊天窗口放大、看板卡片负责人行和 XML 解析依赖都做了稳定性修复",
],
},
{
version: "0.3.8",
date: "2026-05-25",
title: "iOS 客户端、Helm 自托管与更顺畅的协作体验",
changes: [],
+2 -1
View File
@@ -35,7 +35,8 @@
],
"overrides": {
"@types/react": "catalog:",
"@types/react-dom": "catalog:"
"@types/react-dom": "catalog:",
"@xmldom/xmldom": "^0.8.13"
}
},
"devDependencies": {
+22
View File
@@ -449,6 +449,8 @@ export class ApiClient {
}
if (params?.open_only) search.set("open_only", "true");
if (params?.scheduled) search.set("scheduled", "true");
if (params?.sort_by) search.set("sort", params.sort_by);
if (params?.sort_direction) search.set("direction", params.sort_direction);
const path = `/api/issues?${search}`;
const raw = await this.fetch<unknown>(path);
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
@@ -484,6 +486,8 @@ export class ApiClient {
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
if (params.sort_by) search.set("sort", params.sort_by);
if (params.sort_direction) search.set("direction", params.sort_direction);
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, {
endpoint: "GET /api/issues/grouped",
@@ -850,6 +854,24 @@ export class ApiClient {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
// Cascade variant of deleteRuntime. The strict DELETE refuses with
// structured 409 (`code: "runtime_has_active_agents"`, body carries the
// blocking agents) when active agents are bound; the front-end then opens
// the cascade-mode confirmation dialog and submits the user-confirmed
// active agent set here. Server compares the snapshot to the live set
// inside the transaction and refuses with `code: "runtime_delete_plan_changed"`
// (same shape, fresh `active_agents`) if they don't match — caller should
// re-render the agent list and force the user to re-confirm.
async archiveAgentsAndDeleteRuntime(
runtimeId: string,
expectedActiveAgentIds: string[],
): Promise<{ status: string; agents_archived: number; tasks_cancelled: number }> {
return this.fetch(`/api/runtimes/${runtimeId}/archive-agents-and-delete`, {
method: "POST",
body: JSON.stringify({ expected_active_agent_ids: expectedActiveAgentIds }),
});
}
async updateRuntime(
runtimeId: string,
patch: { visibility?: "private" | "public" },
+12 -8
View File
@@ -47,11 +47,11 @@ export function collectDeletedIssueCacheMetadata(
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
collectParentId(parentIssueIds, detail?.parent_issue_id);
collectParentFromListCache(
parentIssueIds,
qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId)),
issueId,
);
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.list(wsId),
})) {
collectParentFromListCache(parentIssueIds, data, issueId);
}
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.myAll(wsId),
@@ -76,9 +76,13 @@ export function pruneDeletedIssueFromListCaches(
wsId: string,
issueId: string,
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? removeIssueFromBuckets(old, issueId) : old,
);
for (const [key] of qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.list(wsId),
})) {
qc.setQueryData<ListIssuesCache>(key, (old) =>
old ? removeIssueFromBuckets(old, issueId) : old,
);
}
for (const [key] of qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.myAll(wsId),
+314
View File
@@ -0,0 +1,314 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus } from "./mutations";
import {
issueKeys,
type IssueSortParam,
} from "./queries";
import type {
GroupedIssuesResponse,
Issue,
ListIssuesCache,
ListIssuesParams,
ListGroupedIssuesParams,
ListIssuesResponse,
} from "../types";
vi.mock("../hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
const WS_ID = "ws-1";
function makeIssue(idx: number, overrides: Partial<Issue> = {}): Issue {
return {
id: `issue-${idx}`,
workspace_id: WS_ID,
number: idx,
identifier: `MUL-${idx}`,
title: `Issue ${idx}`,
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: idx,
start_date: null,
due_date: null,
labels: [],
metadata: {},
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
...overrides,
};
}
function createWrapper(qc: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
describe("useLoadMoreByStatus", () => {
let qc: QueryClient;
let listIssues: ReturnType<typeof vi.fn<(p?: ListIssuesParams) => Promise<ListIssuesResponse>>>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listIssues = vi.fn();
setApiInstance({ listIssues } as unknown as ApiClient);
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("targets the sorted cache key and forwards sort to the API", async () => {
const sort: IssueSortParam = { sort_by: "priority", sort_direction: "desc" };
const activeKey = issueKeys.listSorted(WS_ID, sort);
const seed: ListIssuesCache = {
byStatus: {
todo: { issues: [makeIssue(1)], total: 3 },
},
};
qc.setQueryData<ListIssuesCache>(activeKey, seed);
listIssues.mockResolvedValue({
issues: [makeIssue(2), makeIssue(3)],
total: 3,
});
const { result } = renderHook(
() => useLoadMoreByStatus("todo", undefined, sort),
{ wrapper: createWrapper(qc) },
);
expect(result.current.hasMore).toBe(true);
expect(result.current.total).toBe(3);
await act(async () => {
await result.current.loadMore();
});
expect(listIssues).toHaveBeenCalledWith({
status: "todo",
limit: 50,
offset: 1,
sort_by: "priority",
sort_direction: "desc",
});
const updated = qc.getQueryData<ListIssuesCache>(activeKey);
expect(updated?.byStatus.todo?.issues).toHaveLength(3);
expect(updated?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
"issue-1",
"issue-2",
"issue-3",
]);
});
it("ignores a stale cache entry under a different sort", async () => {
// Stale entry from a previous sort lingers (kept by gcTime / keepPreviousData).
const staleSort: IssueSortParam = { sort_by: "priority", sort_direction: "desc" };
qc.setQueryData<ListIssuesCache>(issueKeys.listSorted(WS_ID, staleSort), {
byStatus: { todo: { issues: [makeIssue(99)], total: 99 } },
});
// The active sort cache has its own bucket — load-more must target THIS one.
const activeSort: IssueSortParam = { sort_by: "position", sort_direction: undefined };
const activeKey = issueKeys.listSorted(WS_ID, activeSort);
qc.setQueryData<ListIssuesCache>(activeKey, {
byStatus: { todo: { issues: [makeIssue(1)], total: 2 } },
});
listIssues.mockResolvedValue({
issues: [makeIssue(2)],
total: 2,
});
const { result } = renderHook(
() => useLoadMoreByStatus("todo", undefined, activeSort),
{ wrapper: createWrapper(qc) },
);
// total derives from the active key, not the stale one.
expect(result.current.total).toBe(2);
await act(async () => {
await result.current.loadMore();
});
expect(listIssues).toHaveBeenCalledWith(
expect.objectContaining({ offset: 1, sort_by: "position" }),
);
const active = qc.getQueryData<ListIssuesCache>(activeKey);
expect(active?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
"issue-1",
"issue-2",
]);
// Stale cache is untouched.
const stale = qc.getQueryData<ListIssuesCache>(issueKeys.listSorted(WS_ID, staleSort));
expect(stale?.byStatus.todo?.issues.map((i) => i.id)).toEqual(["issue-99"]);
});
it("targets the myList scoped cache when myIssues is provided", async () => {
const sort: IssueSortParam = { sort_by: "title", sort_direction: "asc" };
const myIssues = { scope: "assigned", filter: { assignee_id: "user-1" } };
const activeKey = issueKeys.myListSorted(WS_ID, myIssues.scope, myIssues.filter, sort);
qc.setQueryData<ListIssuesCache>(activeKey, {
byStatus: { in_progress: { issues: [makeIssue(1, { status: "in_progress" })], total: 2 } },
});
listIssues.mockResolvedValue({
issues: [makeIssue(2, { status: "in_progress" })],
total: 2,
});
const { result } = renderHook(
() => useLoadMoreByStatus("in_progress", myIssues, sort),
{ wrapper: createWrapper(qc) },
);
await act(async () => {
await result.current.loadMore();
});
expect(listIssues).toHaveBeenCalledWith({
status: "in_progress",
limit: 50,
offset: 1,
sort_by: "title",
sort_direction: "asc",
assignee_id: "user-1",
});
const updated = qc.getQueryData<ListIssuesCache>(activeKey);
expect(updated?.byStatus.in_progress?.issues).toHaveLength(2);
});
it("works with no sort (matches the {} key used by sort-less callers)", async () => {
const myIssues = { scope: "actor", filter: { assignee_id: "user-2" } };
const activeKey = issueKeys.myListSorted(WS_ID, myIssues.scope, myIssues.filter, undefined);
qc.setQueryData<ListIssuesCache>(activeKey, {
byStatus: { todo: { issues: [makeIssue(1)], total: 2 } },
});
listIssues.mockResolvedValue({ issues: [makeIssue(2)], total: 2 });
const { result } = renderHook(
() => useLoadMoreByStatus("todo", myIssues),
{ wrapper: createWrapper(qc) },
);
expect(result.current.total).toBe(2);
expect(result.current.hasMore).toBe(true);
await act(async () => {
await result.current.loadMore();
});
const updated = qc.getQueryData<ListIssuesCache>(activeKey);
expect(updated?.byStatus.todo?.issues).toHaveLength(2);
});
});
describe("useLoadMoreByAssigneeGroup", () => {
let qc: QueryClient;
let listGroupedIssues: ReturnType<
typeof vi.fn<(p: ListGroupedIssuesParams) => Promise<GroupedIssuesResponse>>
>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listGroupedIssues = vi.fn();
setApiInstance({ listGroupedIssues } as unknown as ApiClient);
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("forwards sort to the grouped API and appends into the right group", async () => {
const sort: IssueSortParam = { sort_by: "priority", sort_direction: "desc" };
const queryKey = ["custom", "assignee-groups", "ws-1"] as const;
const seed: GroupedIssuesResponse = {
groups: [
{
id: "assignee:member:user-1",
assignee_type: "member",
assignee_id: "user-1",
issues: [makeIssue(1, { assignee_type: "member", assignee_id: "user-1" })],
total: 2,
},
],
};
qc.setQueryData<GroupedIssuesResponse>(queryKey, seed);
listGroupedIssues.mockResolvedValue({
groups: [
{
id: "assignee:member:user-1",
assignee_type: "member",
assignee_id: "user-1",
issues: [makeIssue(2, { assignee_type: "member", assignee_id: "user-1" })],
total: 2,
},
],
});
const { result } = renderHook(
() =>
useLoadMoreByAssigneeGroup(
{
id: "assignee:member:user-1",
assignee_type: "member",
assignee_id: "user-1",
},
queryKey,
{ statuses: ["todo"] },
sort,
),
{ wrapper: createWrapper(qc) },
);
expect(result.current.hasMore).toBe(true);
expect(result.current.total).toBe(2);
await act(async () => {
await result.current.loadMore();
});
expect(listGroupedIssues).toHaveBeenCalledWith({
group_by: "assignee",
limit: 50,
offset: 1,
sort_by: "priority",
sort_direction: "desc",
statuses: ["todo"],
group_assignee_type: "member",
group_assignee_id: "user-1",
});
const updated = qc.getQueryData<GroupedIssuesResponse>(queryKey);
expect(updated?.groups[0]?.issues.map((i) => i.id)).toEqual([
"issue-1",
"issue-2",
]);
});
});
+71 -32
View File
@@ -5,6 +5,7 @@ import {
issueKeys,
ISSUE_PAGE_SIZE,
type AssigneeGroupedIssuesFilter,
type IssueSortParam,
type MyIssuesFilter,
} from "./queries";
import {
@@ -58,19 +59,26 @@ export type ToggleIssueReactionVars = {
* Paginate one status column into the cache. Works for both the workspace
* issue list and per-scope My Issues lists (pass `myIssues` to target the
* latter).
*
* `sort` must match the sort the consuming `useQuery` was called with —
* the query key embeds it (see `listSorted` / `myListSorted`), so a load-more
* with the wrong sort would patch a stale cache entry that nobody is
* subscribed to. It is also threaded into the API request so the appended
* page lines up with the server-side ordering of the existing items.
*/
export function useLoadMoreByStatus(
status: IssueStatus,
myIssues?: { scope: string; filter: MyIssuesFilter },
sort?: IssueSortParam,
) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
const queryKey = myIssues
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
: issueKeys.list(wsId);
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
const activeKey = myIssues
? issueKeys.myListSorted(wsId, myIssues.scope, myIssues.filter, sort)
: issueKeys.listSorted(wsId, sort);
const cache = qc.getQueryData<ListIssuesCache>(activeKey);
const bucket = cache?.byStatus[status];
const loaded = bucket?.issues.length ?? 0;
const total = bucket?.total ?? 0;
@@ -84,9 +92,10 @@ export function useLoadMoreByStatus(
status,
limit: ISSUE_PAGE_SIZE,
offset: loaded,
...sort,
...myIssues?.filter,
});
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
qc.setQueryData<ListIssuesCache>(activeKey, (old) => {
if (!old) return old;
const prev = getBucket(old, status);
const existingIds = new Set(prev.issues.map((i) => i.id));
@@ -99,15 +108,23 @@ export function useLoadMoreByStatus(
} finally {
setIsLoading(false);
}
}, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]);
}, [qc, activeKey, status, loaded, hasMore, isLoading, myIssues?.filter, sort]);
return { loadMore, hasMore, isLoading, total };
}
/**
* Paginate one assignee-grouped board column into the cache. `queryKey`
* already pins the active cache entry (it's the same object the consuming
* `useQuery` registered), so the cache lookup and `setQueryData` target the
* right row. `sort` is threaded into the API request so the appended page
* lines up with the server-side ordering of the existing items.
*/
export function useLoadMoreByAssigneeGroup(
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
queryKey: QueryKey,
filter: AssigneeGroupedIssuesFilter,
sort?: IssueSortParam,
) {
const qc = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
@@ -126,6 +143,7 @@ export function useLoadMoreByAssigneeGroup(
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: loaded,
...sort,
...filter,
group_assignee_type: group.assignee_type ?? "none",
group_assignee_id: group.assignee_id ?? undefined,
@@ -151,7 +169,7 @@ export function useLoadMoreByAssigneeGroup(
} finally {
setIsLoading(false);
}
}, [filter, group.assignee_id, group.assignee_type, hasMore, isLoading, loaded, qc, queryKey]);
}, [filter, group.assignee_id, group.assignee_type, hasMore, isLoading, loaded, qc, queryKey, sort]);
return { loadMore, hasMore, isLoading, total };
}
@@ -166,9 +184,9 @@ export function useCreateIssue() {
return useMutation({
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
onSuccess: (newIssue) => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? addIssueToBuckets(old, newIssue) : old,
);
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
if (data) qc.setQueryData<ListIssuesCache>(key, addIssueToBuckets(data, newIssue));
}
// Surface the just-created issue in cmd+k's Recent list without
// requiring the user to open it first.
useRecentIssuesStore.getState().recordVisit(wsId, newIssue.id);
@@ -199,7 +217,8 @@ export function useUpdateIssue() {
// yield to the event loop, letting @dnd-kit reset its visual state
// before the optimistic update lands.
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
const firstListData = prevLists[0]?.[1];
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
// Resolve parent_issue_id from the freshest source so we can keep the
@@ -209,7 +228,7 @@ export function useUpdateIssue() {
// child may live only there, not in detail/list.
let parentId: string | null =
prevDetail?.parent_issue_id ??
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
(firstListData ? findIssueLocation(firstListData, id)?.issue.parent_issue_id : null) ??
null;
if (!parentId) {
const childrenCaches = qc.getQueriesData<Issue[]>({
@@ -228,9 +247,9 @@ export function useUpdateIssue() {
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
: undefined;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, id, data) : old,
);
for (const [key, cached] of prevLists) {
if (cached) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(cached, id, data));
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
old ? { ...old, ...data } : old,
);
@@ -241,10 +260,14 @@ export function useUpdateIssue() {
old?.map((c) => (c.id === id ? { ...c, ...data } : c)),
);
}
return { prevList, prevDetail, prevChildren, parentId, id };
return { prevLists, prevDetail, prevChildren, parentId, id };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevLists) {
for (const [key, snapshot] of ctx.prevLists) {
qc.setQueryData(key, snapshot);
}
}
if (ctx?.prevDetail)
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
if (ctx?.parentId && ctx.prevChildren !== undefined) {
@@ -302,7 +325,7 @@ export function useDeleteIssue() {
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
),
);
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.myAll(wsId),
});
@@ -318,10 +341,14 @@ export function useDeleteIssue() {
pruneDeletedIssueFromListCaches(qc, wsId, id);
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { id, metadata, prevList, prevMyLists, prevDetail, prevChildren };
return { id, metadata, prevLists, prevMyLists, prevDetail, prevChildren };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevLists) {
for (const [key, snapshot] of ctx.prevLists) {
qc.setQueryData(key, snapshot);
}
}
if (ctx?.prevMyLists) {
for (const [key, snapshot] of ctx.prevMyLists) {
qc.setQueryData(key, snapshot);
@@ -362,13 +389,13 @@ export function useBatchUpdateIssues() {
}) => api.batchUpdateIssues(ids, updates),
onMutate: async ({ ids, updates }) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
if (!old) return old;
let next = old;
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
for (const [key, cached] of prevLists) {
if (!cached) continue;
let next = cached;
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
return next;
});
qc.setQueryData<ListIssuesCache>(key, next);
}
// Mirror the optimistic patch into any loaded children cache so
// sub-issue rows on a parent's detail page reflect the change too.
@@ -389,10 +416,14 @@ export function useBatchUpdateIssues() {
);
}
return { prevList, prevChildren, affectedParentIds };
return { prevLists, prevChildren, affectedParentIds };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevLists) {
for (const [key, snapshot] of ctx.prevLists) {
qc.setQueryData(key, snapshot);
}
}
if (ctx?.prevChildren) {
for (const [parentId, snapshot] of ctx.prevChildren) {
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
@@ -443,7 +474,7 @@ export function useBatchDeleteIssues() {
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
),
);
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.myAll(wsId),
});
@@ -462,10 +493,14 @@ export function useBatchDeleteIssues() {
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
}
}
return { prevList, prevMyLists, prevChildren, parentIssueIds, metadataById };
return { prevLists, prevMyLists, prevChildren, parentIssueIds, metadataById };
},
onError: (_err, _ids, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevLists) {
for (const [key, snapshot] of ctx.prevLists) {
qc.setQueryData(key, snapshot);
}
}
if (ctx?.prevMyLists) {
for (const [key, snapshot] of ctx.prevMyLists) {
qc.setQueryData(key, snapshot);
@@ -485,7 +520,11 @@ export function useBatchDeleteIssues() {
return;
}
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevLists) {
for (const [key, snapshot] of ctx.prevLists) {
qc.setQueryData(key, snapshot);
}
}
if (ctx?.prevMyLists) {
for (const [key, snapshot] of ctx.prevMyLists) {
qc.setQueryData(key, snapshot);
+40 -17
View File
@@ -1,4 +1,4 @@
import { queryOptions } from "@tanstack/react-query";
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type {
GroupedIssuesResponse,
@@ -10,18 +10,30 @@ import type {
} from "../types";
import { BOARD_STATUSES } from "./config";
export interface IssueSortParam {
sort_by?: ListIssuesParams["sort_by"];
sort_direction?: ListIssuesParams["sort_direction"];
}
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
/** PREFIX for invalidation — no sort. */
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
/** FULL KEY for queryOptions — includes sort. */
listSorted: (wsId: string, sort?: IssueSortParam) =>
[...issueKeys.list(wsId), sort ?? {}] as const,
assigneeGroupsAll: (wsId: string) =>
[...issueKeys.all(wsId), "assignee-groups"] as const,
assigneeGroups: (wsId: string, filter: AssigneeGroupedIssuesFilter) =>
[...issueKeys.assigneeGroupsAll(wsId), filter] as const,
/** All "my issues" queries — use for bulk invalidation. */
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
/** Per-scope "my issues" list with filter identity baked into the key. */
/** PREFIX for per-scope invalidation — no sort. */
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
[...issueKeys.myAll(wsId), scope, filter] as const,
/** FULL KEY for queryOptions — includes sort. */
myListSorted: (wsId: string, scope: string, filter: MyIssuesFilter, sort?: IssueSortParam) =>
[...issueKeys.myList(wsId, scope, filter), sort ?? {}] as const,
myAssigneeGroupsAll: (wsId: string) =>
[...issueKeys.myAll(wsId), "assignee-groups"] as const,
myAssigneeGroups: (
@@ -91,10 +103,10 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
return out;
}
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
async function fetchFirstPages(filter: MyIssuesFilter = {}, sort?: IssueSortParam): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_STATUSES.map((status) =>
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }),
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...sort, ...filter }),
),
);
const byStatus: ListIssuesCache["byStatus"] = {};
@@ -124,11 +136,11 @@ async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesC
* total — pagination on the "All" scope is out of scope; the first
* 50-per-status × 3 widening (deduped) is what the page renders.
*/
async function fetchAllMyFirstPages(userId: string): Promise<ListIssuesCache> {
async function fetchAllMyFirstPages(userId: string, sort?: IssueSortParam): Promise<ListIssuesCache> {
const [byAssignee, byCreator, byInvolves] = await Promise.all([
fetchFirstPages({ assignee_id: userId }),
fetchFirstPages({ creator_id: userId }),
fetchFirstPages({ involves_user_id: userId }),
fetchFirstPages({ assignee_id: userId }, sort),
fetchFirstPages({ creator_id: userId }, sort),
fetchFirstPages({ involves_user_id: userId }, sort),
]);
const byStatus: ListIssuesCache["byStatus"] = {};
for (const status of PAGINATED_STATUSES) {
@@ -158,6 +170,7 @@ async function fetchAllMyFirstPages(userId: string): Promise<ListIssuesCache> {
async function fetchAllMyAssigneeGroups(
userId: string,
filter: AssigneeGroupedIssuesFilter,
sort?: IssueSortParam,
): Promise<GroupedIssuesResponse> {
const variants: AssigneeGroupedIssuesFilter[] = [
{ ...filter, assignee_id: userId },
@@ -170,6 +183,7 @@ async function fetchAllMyAssigneeGroups(
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...sort,
...f,
}),
),
@@ -210,27 +224,31 @@ async function fetchAllMyAssigneeGroups(
* Fetches the first page of each paginated status in parallel. Use
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
*/
export function issueListOptions(wsId: string) {
export function issueListOptions(wsId: string, sort?: IssueSortParam) {
return queryOptions({
queryKey: issueKeys.list(wsId),
queryFn: () => fetchFirstPages(),
queryKey: issueKeys.listSorted(wsId, sort),
queryFn: () => fetchFirstPages({}, sort),
select: flattenIssueBuckets,
placeholderData: keepPreviousData,
});
}
export function issueAssigneeGroupsOptions(
wsId: string,
filter: AssigneeGroupedIssuesFilter,
sort?: IssueSortParam,
) {
return queryOptions<GroupedIssuesResponse>({
queryKey: issueKeys.assigneeGroups(wsId, filter),
queryKey: issueKeys.assigneeGroups(wsId, { ...filter, ...sort }),
queryFn: () =>
api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...sort,
...filter,
}),
placeholderData: keepPreviousData,
});
}
@@ -247,14 +265,16 @@ export function myIssueListOptions(
// scope the filter object already carries the relevant id and userId
// is ignored.
userId?: string,
sort?: IssueSortParam,
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),
queryKey: issueKeys.myListSorted(wsId, scope, filter, sort),
queryFn: () =>
scope === "all" && userId
? fetchAllMyFirstPages(userId)
: fetchFirstPages(filter),
? fetchAllMyFirstPages(userId, sort)
: fetchFirstPages(filter, sort),
select: flattenIssueBuckets,
placeholderData: keepPreviousData,
});
}
@@ -318,18 +338,21 @@ export function myIssueAssigneeGroupsOptions(
// See myIssueListOptions for the userId contract — only consulted when
// scope === "all", and powers the 3-fetch grouped union.
userId?: string,
sort?: IssueSortParam,
) {
return queryOptions<GroupedIssuesResponse>({
queryKey: issueKeys.myAssigneeGroups(wsId, scope, filter),
queryKey: issueKeys.myAssigneeGroups(wsId, scope, { ...filter, ...sort }),
queryFn: () =>
scope === "all" && userId
? fetchAllMyAssigneeGroups(userId, filter)
? fetchAllMyAssigneeGroups(userId, filter, sort)
: api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...sort,
...filter,
}),
placeholderData: keepPreviousData,
});
}
+16 -1
View File
@@ -9,7 +9,7 @@ import { ALL_STATUSES } from "../config";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list" | "gantt";
export type ViewMode = "board" | "list" | "gantt" | "swimlane";
export type GanttZoom = "day" | "week" | "month";
export type IssueGrouping = "status" | "assignee";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
@@ -79,6 +79,8 @@ export interface IssueViewState {
listCollapsedStatuses: IssueStatus[];
ganttZoom: GanttZoom;
ganttShowCompleted: boolean;
swimlaneOrder: string[];
collapsedSwimlanes: string[];
setViewMode: (mode: ViewMode) => void;
setGanttZoom: (zoom: GanttZoom) => void;
toggleGanttShowCompleted: () => void;
@@ -99,6 +101,8 @@ export interface IssueViewState {
setSortDirection: (dir: SortDirection) => void;
toggleCardProperty: (key: keyof CardProperties) => void;
toggleListCollapsed: (status: IssueStatus) => void;
setSwimlaneOrder: (order: string[]) => void;
toggleSwimlaneCollapsed: (key: string) => void;
}
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
@@ -128,6 +132,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
listCollapsedStatuses: [],
ganttZoom: "week",
ganttShowCompleted: false,
swimlaneOrder: [],
collapsedSwimlanes: [],
setViewMode: (mode) => set({ viewMode: mode }),
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
@@ -233,6 +239,13 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
? state.listCollapsedStatuses.filter((s) => s !== status)
: [...state.listCollapsedStatuses, status],
})),
setSwimlaneOrder: (order) => set({ swimlaneOrder: order }),
toggleSwimlaneCollapsed: (key) =>
set((state) => ({
collapsedSwimlanes: state.collapsedSwimlanes.includes(key)
? state.collapsedSwimlanes.filter((k) => k !== key)
: [...state.collapsedSwimlanes, key],
})),
});
export const viewStorePersistOptions = (name: string) => ({
@@ -259,6 +272,8 @@ export const viewStorePersistOptions = (name: string) => ({
listCollapsedStatuses: state.listCollapsedStatuses,
ganttZoom: state.ganttZoom,
ganttShowCompleted: state.ganttShowCompleted,
swimlaneOrder: state.swimlaneOrder,
collapsedSwimlanes: state.collapsedSwimlanes,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
// saved before a new toggle was introduced wins entirely and the new key is
+18 -14
View File
@@ -15,9 +15,9 @@ export function onIssueCreated(
wsId: string,
issue: Issue,
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? addIssueToBuckets(old, issue) : old,
);
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
if (data) qc.setQueryData<ListIssuesCache>(key, addIssueToBuckets(data, issue));
}
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
@@ -40,20 +40,24 @@ export function onIssueUpdated(
// Look up the OLD parent before mutating list state, so we can keep
// the parent's children cache in sync (powers the sub-issues list
// shown on the parent issue page).
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const listQueries = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
const firstListData = listQueries[0]?.[1];
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
const oldParentId =
detailData?.parent_issue_id ??
(listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ??
(firstListData ? findIssueLocation(firstListData, issue.id)?.issue.parent_issue_id : null) ??
null;
// The NEW parent comes from the WS payload when parent_issue_id changed
const newParentId = issue.parent_issue_id ?? null;
const parentChanged =
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, issue.id, issue) : old,
);
for (const [key, data] of listQueries) {
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issue.id, issue));
}
if (issue.position !== undefined) {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
}
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
@@ -105,9 +109,9 @@ export function onIssueLabelsChanged(
issueId: string,
labels: Label[],
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, issueId, { labels }) : old,
);
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issueId, { labels }));
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
@@ -148,9 +152,9 @@ export function onIssueMetadataChanged(
issueId: string,
metadata: IssueMetadata,
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, issueId, { metadata }) : old,
);
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issueId, { metadata }));
}
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, metadata } : old,
);
+30
View File
@@ -1,6 +1,8 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { runtimeKeys } from "./queries";
import { workspaceKeys } from "../workspace/queries";
import { agentTaskSnapshotKeys } from "../agents/queries";
export function useDeleteRuntime(wsId: string) {
const qc = useQueryClient();
@@ -12,6 +14,34 @@ export function useDeleteRuntime(wsId: string) {
});
}
// Cascade-mode counterpart to useDeleteRuntime. The dialog routes here when
// the strict DELETE refused with `runtime_has_active_agents` (or when the
// caller already knows the runtime has active agents and wants to skip the
// pre-flight refusal). Mutation fn returns the server-reported counts so
// the caller can render a richer success toast.
//
// Invalidates runtimes (the list / detail), workspace agents (the cascade
// archives them) and the agent presence snapshot (cascade also cancels
// queued/running tasks). Without the agent-side invalidation the Agents
// page would keep showing the just-archived rows as live until a refetch.
export function useArchiveAgentsAndDeleteRuntime(wsId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
runtimeId,
expectedActiveAgentIds,
}: {
runtimeId: string;
expectedActiveAgentIds: string[];
}) => api.archiveAgentsAndDeleteRuntime(runtimeId, expectedActiveAgentIds),
onSettled: () => {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) });
},
});
}
// useUpdateRuntime patches editable fields on a runtime (visibility).
// Invalidates the runtime list so the picker disabled-state recomputes.
export function useUpdateRuntime(wsId: string) {
-26
View File
@@ -4,18 +4,6 @@ export type AgentRuntimeMode = "local" | "cloud";
export type AgentVisibility = "workspace" | "private";
/**
* Per-agent toggle controlling whether the runtime merges the host machine's
* user-global skill directory into the agent. "merge" (default) preserves the
* inherit-from-machine behavior; "ignore" isolates the runtime so a broken
* local skill on one operator's machine cannot crash a shared agent (#3052).
* Today only the Claude runtime honours the switch — others either already
* isolate (Codex via CODEX_HOME) or treat it as a no-op. Older backends omit
* the field; clients MUST treat undefined as "merge" so the documented
* default wins on drift.
*/
export type AgentSkillsLocal = "ignore" | "merge";
// Runtime visibility is a separate axis from agent visibility — different
// vocabulary because it gates a different action. "private" (default) means
// only the runtime owner and workspace admins can bind agents to it;
@@ -180,14 +168,6 @@ export interface Agent {
* (MUL-2339).
*/
thinking_level?: string;
/**
* Per-agent toggle for merging the host machine's user-global skill
* directory (e.g. Claude's `~/.claude/skills/`) into the agent. Older
* backends that predate the column omit this field; consumers MUST
* default to `"merge"` on drift to match the documented platform
* default (#3052 hardening is opt-in via `"ignore"`).
*/
skills_local?: AgentSkillsLocal;
owner_id: string | null;
skills: AgentSkillSummary[];
created_at: string;
@@ -223,10 +203,6 @@ export interface CreateAgentRequest {
model?: string;
/** Optional runtime-native reasoning/effort token. See `Agent.thinking_level`. */
thinking_level?: string;
/** Per-agent host-skill merge toggle. Defaults to `"merge"` server-side
* when omitted (inherit-from-machine behavior); pass `"ignore"` to
* isolate the agent from the host's user-global skill directory. */
skills_local?: AgentSkillsLocal;
/** Optional template slug used by the onboarding agent picker. Surfaced
* as the `template` property on the `agent_created` PostHog event. */
template?: string;
@@ -332,8 +308,6 @@ export interface UpdateAgentRequest {
* runtime's provider enum, rejected with 400 if not recognised
*/
thinking_level?: string;
/** Update the host-skill merge toggle. Omit to leave unchanged. */
skills_local?: AgentSkillsLocal;
}
/**
+4
View File
@@ -64,6 +64,8 @@ export interface ListIssuesParams {
* majority on the client.
*/
scheduled?: boolean;
sort_by?: "position" | "priority" | "title" | "created_at" | "start_date" | "due_date";
sort_direction?: "asc" | "desc";
}
export interface IssueActorRef {
@@ -95,6 +97,8 @@ export interface ListGroupedIssuesParams {
label_ids?: string[];
group_assignee_type?: IssueAssigneeType | "none";
group_assignee_id?: string;
sort_by?: "position" | "priority" | "title" | "created_at" | "start_date" | "due_date";
sort_direction?: "asc" | "desc";
}
/** Raw backend response shape for `GET /api/issues`. */
-1
View File
@@ -4,7 +4,6 @@ export type {
AgentStatus,
AgentRuntimeMode,
AgentVisibility,
AgentSkillsLocal,
AgentTask,
AgentActivityBucket,
AgentRunCount,
@@ -7,14 +7,12 @@ import { ModelDropdown } from "./model-dropdown";
import { RuntimePicker, isRuntimeUsableForUser } from "./runtime-picker";
import { InstructionsEditor } from "./instructions-editor";
import { SkillMultiSelect } from "./skill-multi-select";
import { SkillsLocalToggle } from "./skills-local-toggle";
import { AvatarPicker } from "./avatar-picker";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { workspaceKeys } from "@multica/core/workspace/queries";
import type {
Agent,
AgentSkillsLocal,
AgentVisibility,
RuntimeDevice,
MemberWithUser,
@@ -94,13 +92,6 @@ export function CreateAgentDialog({
const [selectedSkillIds, setSelectedSkillIds] = useState<Set<string>>(
() => new Set(template?.skills.map((s) => s.id) ?? []),
);
// Default to "merge" for new agents (matches the server-side default —
// inherit-from-machine behavior preserved). Duplicate mode carries the
// source agent's explicit "ignore" through so a hardened agent stays
// hardened.
const [skillsLocal, setSkillsLocal] = useState<AgentSkillsLocal>(
() => (template?.skills_local === "ignore" ? "ignore" : "merge"),
);
const [creating, setCreating] = useState(false);
// Duplicate-mode pre-fill: clone lands on the source agent's runtime so
@@ -171,11 +162,6 @@ export function CreateAgentDialog({
model: model.trim() || undefined,
instructions: trimmedInstructions || undefined,
avatar_url: avatarUrl ?? undefined,
// Only send the toggle when the user opted into isolation.
// "merge" is the server-side default; omitting the field keeps the
// request body small and prevents older backends without the
// column from rejecting the request.
skills_local: skillsLocal === "ignore" ? "ignore" : undefined,
};
if (template) {
// Duplicate path: forward the hidden config fields the source
@@ -373,12 +359,6 @@ export function CreateAgentDialog({
selectedIds={selectedSkillIds}
onChange={setSelectedSkillIds}
/>
<SkillsLocalToggle
value={skillsLocal}
onChange={setSkillsLocal}
hintScope="create"
/>
</div>
</div>
@@ -1,67 +0,0 @@
"use client";
import type { AgentSkillsLocal } from "@multica/core/types";
import { Label } from "@multica/ui/components/ui/label";
import { Switch } from "@multica/ui/components/ui/switch";
import { useT } from "../../i18n";
// SkillsLocalToggle renders the per-agent "merge host-machine skills" switch
// shared between the Create dialog (uncontrolled-by-network, only emits the
// new value) and the edit tab (which immediately persists). The component
// stays headless about save behavior — the parent decides what to do on
// change. Section labels follow the create dialog `skills_section` keys so
// both surfaces share identical copy.
export function SkillsLocalToggle({
value,
onChange,
disabled,
hintScope = "create",
}: {
value: AgentSkillsLocal;
onChange: (next: AgentSkillsLocal) => void;
disabled?: boolean;
/**
* Picks the i18n namespace for the secondary hint copy. The create dialog
* lives under `create_dialog.skills_section.local_*` while the edit tab
* uses `tab_body.skills.local_*` — wording is intentionally identical
* today but kept in two places so each surface can evolve independently.
*/
hintScope?: "create" | "tab";
}) {
const { t } = useT("agents");
const checked = value === "merge";
const label =
hintScope === "create"
? t(($) => $.create_dialog.skills_section.local_label)
: t(($) => $.tab_body.skills.local_label);
const hint = checked
? hintScope === "create"
? t(($) => $.create_dialog.skills_section.local_hint_on)
: t(($) => $.tab_body.skills.local_hint_on)
: hintScope === "create"
? t(($) => $.create_dialog.skills_section.local_hint_off)
: t(($) => $.tab_body.skills.local_hint_off);
return (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
<Label
htmlFor="skills-local-toggle"
className="cursor-pointer text-sm font-medium leading-tight"
>
{label}
</Label>
<Switch
id="skills-local-toggle"
checked={checked}
onCheckedChange={(next: boolean) => onChange(next ? "merge" : "ignore")}
disabled={disabled}
/>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
{hint}
</p>
</div>
);
}
@@ -11,7 +11,6 @@ import enAgents from "../../../locales/en/agents.json";
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
const mockListSkills = vi.hoisted(() => vi.fn());
const mockUpdateAgent = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
@@ -21,7 +20,6 @@ vi.mock("@multica/core/api", () => ({
api: {
listSkills: (...args: unknown[]) => mockListSkills(...args),
setAgentSkills: vi.fn(),
updateAgent: (...args: unknown[]) => mockUpdateAgent(...args),
},
}));
@@ -57,7 +55,7 @@ const agent: Agent = {
archived_by: null,
};
function renderSkillsTab(overrides: Partial<Agent> = {}) {
function renderSkillsTab() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -69,7 +67,7 @@ function renderSkillsTab(overrides: Partial<Agent> = {}) {
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<SkillsTab agent={{ ...agent, ...overrides }} />
<SkillsTab agent={agent} />
</QueryClientProvider>
</I18nProvider>,
);
@@ -79,7 +77,6 @@ describe("SkillsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListSkills.mockResolvedValue([]);
mockUpdateAgent.mockResolvedValue({});
});
it("does not render the inline Local Runtime Skills section even for local-runtime agents", async () => {
@@ -91,13 +88,9 @@ describe("SkillsTab", () => {
renderSkillsTab();
// Top informational callout should still render; that's how we know
// the tab body itself rendered (not stuck in a loading state). The
// toggle hint also includes the same phrase, so anchor on the intro
// copy specifically.
// the tab body itself rendered (not stuck in a loading state).
expect(
await screen.findByText(
/Workspace skills assigned to this agent/i,
),
await screen.findByText(/Local runtime skills are always available/i),
).toBeInTheDocument();
// The removed section's heading and its trigger button must be gone.
@@ -105,29 +98,13 @@ describe("SkillsTab", () => {
expect(
screen.queryByRole("button", { name: /Import to Workspace/i }),
).not.toBeInTheDocument();
});
it("renders the local-skill toggle ON by default (merge — preserves inherit-from-machine behavior)", async () => {
// Bohan's MUL-2603 product decision: keep the legacy "merge" default so
// personal workflows that rely on locally installed Claude Skills keep
// working unchanged. Owners flip the toggle off ("ignore") when they
// explicitly need to harden a shared agent.
renderSkillsTab();
const toggle = await screen.findByRole("switch", {
name: /Allow locally installed skills/i,
});
// Base UI Switch reflects state via data-state; aria-checked is the
// accessible read.
expect(toggle.getAttribute("aria-checked")).toBe("true");
});
it("reflects ignore mode when the agent opted into isolation", async () => {
renderSkillsTab({ skills_local: "ignore" });
const toggle = await screen.findByRole("switch", {
name: /Allow locally installed skills/i,
});
expect(toggle.getAttribute("aria-checked")).toBe("false");
// No runtime list / local-skills query should be wired up either —
// we removed @multica/core/runtimes from this file's imports.
// Surface it via behaviour: the `agent` here has runtime_id but the
// tab must not invoke any runtime-list mock to render. (Both are
// already deleted from the mock setup above; this assertion is
// implicit — the test file would fail to import if the component
// still referenced runtimeListOptions / runtimeLocalSkillsOptions.)
});
});
@@ -1,10 +1,10 @@
"use client";
import { useState } from "react";
import { FileText, Info, Plus, Trash2 } from "lucide-react";
import { FileText, Plus, Trash2 } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Agent, AgentSkillsLocal } from "@multica/core/types";
import type { Agent } from "@multica/core/types";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import {
@@ -13,7 +13,6 @@ import {
} from "@multica/core/workspace/queries";
import { Button } from "@multica/ui/components/ui/button";
import { SkillAddDialog } from "../skill-add-dialog";
import { SkillsLocalToggle } from "../skills-local-toggle";
import { useT } from "../../../i18n";
export function SkillsTab({
@@ -24,12 +23,6 @@ export function SkillsTab({
const { t } = useT("agents");
const qc = useQueryClient();
const wsId = useWorkspaceId();
// Default missing values to "merge". Older backends omit the field
// entirely; the documented platform default wins on drift (see
// AgentSkillsLocal — "ignore" is the explicit hardening opt-in).
const currentSkillsLocal: AgentSkillsLocal =
agent.skills_local === "ignore" ? "ignore" : "merge";
const [skillsLocalSaving, setSkillsLocalSaving] = useState(false);
// Same query the SkillAddDialog uses (TanStack Query dedupes by key, so
// this isn't an extra request) — used here only to grey out the "Add
// skill" button when the workspace has zero skills total. When skills
@@ -41,24 +34,6 @@ export function SkillsTab({
const [removing, setRemoving] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const handleSkillsLocalChange = async (next: AgentSkillsLocal) => {
if (next === currentSkillsLocal || skillsLocalSaving) return;
setSkillsLocalSaving(true);
try {
await api.updateAgent(agent.id, { skills_local: next });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
toast.success(t(($) => $.tab_body.skills.local_saved_toast));
} catch (e) {
toast.error(
e instanceof Error
? e.message
: t(($) => $.tab_body.skills.local_save_failed_toast),
);
} finally {
setSkillsLocalSaving(false);
}
};
const handleRemove = async (skillId: string) => {
setRemoving(true);
try {
@@ -76,7 +51,7 @@ export function SkillsTab({
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.intro)}
</p>
@@ -92,21 +67,6 @@ export function SkillsTab({
</Button>
</div>
<div className="flex items-start gap-2 rounded-md border border-info/20 bg-info/5 px-3 py-2.5">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-info" />
<p className="text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.import_hint)}
</p>
</div>
<SkillsLocalToggle
value={currentSkillsLocal}
onChange={handleSkillsLocalChange}
disabled={skillsLocalSaving}
hintScope="tab"
/>
{agent.skills.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
<FileText className="h-8 w-8 text-muted-foreground/40" />
+1 -1
View File
@@ -206,7 +206,7 @@ export function ActorIssuesPanel({
<p className="text-sm">{t(($) => $.actor_issues.search_empty)}</p>
</div>
) : (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex flex-1 min-h-0 flex-col p-1">
<ListView
issues={issues}
visibleStatuses={visibleStatuses}
@@ -83,6 +83,17 @@ function fakeQc(data: {
);
return {
getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)),
getQueriesData: <T,>(filter: { queryKey: readonly unknown[] }) => {
const prefix = filter.queryKey as unknown[];
const results: [readonly unknown[], T][] = [];
for (const [k, v] of map) {
const parsed = JSON.parse(k) as unknown[];
if (parsed.length >= prefix.length && prefix.every((seg, i) => JSON.stringify(seg) === JSON.stringify(parsed[i]))) {
results.push([parsed, v as T]);
}
}
return results;
},
} as unknown as QueryClient;
}
@@ -388,7 +388,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? [];
const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? [];
const squads: Squad[] = qc.getQueryData(workspaceKeys.squads(wsId)) ?? [];
const cachedResponse = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const listQueries = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
const cachedResponse = listQueries[0]?.[1];
const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : [];
// Read current user identity imperatively — this factory runs outside
@@ -312,7 +312,7 @@ const animateLayoutChanges: AnimateLayoutChanges = (args) => {
return defaultAnimateLayoutChanges(args);
};
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue, childProgress }: { issue: Issue; childProgress?: ChildProgress }) {
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue, childProgress, disableSorting }: { issue: Issue; childProgress?: ChildProgress; disableSorting?: boolean }) {
const p = useWorkspacePaths();
const {
attributes,
@@ -325,6 +325,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue, chil
id: issue.id,
data: { status: issue.status },
animateLayoutChanges,
disabled: disableSorting ? { droppable: true } : undefined,
});
const style = {
@@ -1,6 +1,6 @@
"use client";
import { useMemo, type ReactNode } from "react";
import { memo, useMemo, type ReactNode } from "react";
import { EyeOff, MoreHorizontal, Plus, UserMinus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core";
@@ -22,6 +22,14 @@ import type { ChildProgress } from "./list-row";
import { useT } from "../../i18n";
import { ActorAvatar } from "../../common/actor-avatar";
// Insertion-position prediction intentionally omitted. The server's
// ORDER BY uses PostgreSQL's en_US.utf8 collation (glibc), which
// cannot be faithfully replicated in JavaScript (ICU/V8). Showing an
// inaccurate indicator is worse than showing none.
export const BOARD_COL_WIDTH = 280;
export const BOARD_CARD_WIDTH = BOARD_COL_WIDTH - 16 - 8; // col(280) - col p-2(16) - droppable p-1(8)
export interface BoardColumnGroup {
id: string;
title: string;
@@ -32,7 +40,7 @@ export interface BoardColumnGroup {
createData?: Record<string, unknown>;
}
export function BoardColumn({
export const BoardColumn = memo(function BoardColumn({
group,
issueIds,
issueMap,
@@ -40,6 +48,7 @@ export function BoardColumn({
totalCount,
footer,
projectId,
sortLabel,
}: {
group: BoardColumnGroup;
issueIds: string[];
@@ -49,6 +58,7 @@ export function BoardColumn({
footer?: ReactNode;
/** When set, the per-column "+" pre-fills the project on the create form. */
projectId?: string;
sortLabel?: string | null;
}) {
const status = group.status;
const cfg = status ? STATUS_CONFIG[status] : null;
@@ -67,7 +77,7 @@ export function BoardColumn({
);
return (
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg?.columnBg ?? "bg-muted/40"} p-2`}>
<div style={{ width: BOARD_COL_WIDTH }} className={`flex shrink-0 flex-col rounded-xl ${cfg?.columnBg ?? "bg-muted/40"} p-2`}>
<div className="mb-2 flex items-center justify-between px-1.5">
<BoardGroupHeading group={group} count={totalCount ?? issueIds.length} />
@@ -115,13 +125,24 @@ export function BoardColumn({
</div>
<div
ref={setNodeRef}
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver ? "bg-accent/60" : ""
className={`relative min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver && sortLabel
? "ring-2 ring-brand/25 bg-accent/15"
: isOver
? "bg-accent/60"
: ""
}`}
>
{isOver && sortLabel && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/40">
<span className="rounded-md bg-popover px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-sm border border-border">
{sortLabel}
</span>
</div>
)}
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} childProgress={childProgressMap?.get(issue.id)} />
<DraggableBoardCard key={issue.id} issue={issue} childProgress={childProgressMap?.get(issue.id)} disableSorting={!!sortLabel} />
))}
</SortableContext>
{issueIds.length === 0 && (
@@ -133,7 +154,7 @@ export function BoardColumn({
</div>
</div>
);
}
});
function BoardGroupHeading({
group,
+127 -209
View File
@@ -1,81 +1,41 @@
"use client";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useState, useCallback, useMemo, useEffect, useRef, memo } from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
pointerWithin,
closestCenter,
type CollisionDetection,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import type { QueryKey } from "@tanstack/react-query";
import { arrayMove } from "@dnd-kit/sortable";
import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueAssigneeGroup, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import type { Issue, IssueAssigneeGroup, IssueStatus } from "@multica/core/types";
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus } from "@multica/core/issues/mutations";
import type { AssigneeGroupedIssuesFilter, MyIssuesFilter } from "@multica/core/issues/queries";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
import { useViewStoreApi, useViewStore } from "@multica/core/issues/stores/view-store-context";
import type { IssueGrouping, SortField, SortDirection } from "@multica/core/issues/stores/view-store";
import type { AssigneeGroupedIssuesFilter, IssueSortParam, MyIssuesFilter } from "@multica/core/issues/queries";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import type { IssueGrouping } from "@multica/core/issues/stores/view-store";
import { useActorName } from "@multica/core/workspace/hooks";
import { sortIssues } from "../utils/sort";
import { StatusIcon } from "./status-icon";
import { BoardColumn, type BoardColumnGroup } from "./board-column";
import { BoardColumn, BOARD_CARD_WIDTH, type BoardColumnGroup } from "./board-column";
import { BoardCardContent } from "./board-card";
import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
import type { ChildProgress } from "./list-row";
import { useT } from "../../i18n";
type BoardMoveUpdates = Pick<
UpdateIssueRequest,
"status" | "assignee_type" | "assignee_id" | "position"
>;
const UNASSIGNED_GROUP_ID = "assignee:unassigned";
function makeKanbanCollision(columnIds: Set<string>): CollisionDetection {
return (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) {
// Prefer card collisions over column collisions so that
// dragging down within a column finds the target card
// instead of the column droppable.
const cards = pointer.filter((c) => !columnIds.has(c.id as string));
if (cards.length > 0) return cards;
}
// Fallback: closestCenter finds the nearest card even when
// the pointer is in a gap between cards (common when dragging down).
return closestCenter(args);
};
}
function statusGroupId(status: IssueStatus): string {
return `status:${status}`;
}
function assigneeGroupId(
type: IssueAssigneeType | null,
id: string | null,
): string {
return type && id ? `assignee:${type}:${id}` : UNASSIGNED_GROUP_ID;
}
function getIssueGroupId(issue: Issue, grouping: IssueGrouping): string {
if (grouping === "status") return statusGroupId(issue.status);
return assigneeGroupId(issue.assignee_type, issue.assignee_id);
}
import {
type DragMoveUpdates,
makeKanbanCollision,
statusGroupId,
assigneeGroupId,
buildColumns,
computePosition,
findColumn,
issueMatchesGroup,
getMoveUpdates,
} from "../utils/drag-utils";
function isStatusGroup(
group: BoardColumnGroup,
@@ -145,71 +105,8 @@ function buildGroups(
});
}
/** Build column ID arrays from TQ issue data, respecting current sort. */
function buildColumns(
issues: Issue[],
groups: BoardColumnGroup[],
grouping: IssueGrouping,
sortBy: SortField,
sortDirection: SortDirection,
): Record<string, string[]> {
const cols: Record<string, string[]> = {};
for (const group of groups) {
const sorted = sortIssues(
issues.filter((i) => getIssueGroupId(i, grouping) === group.id),
sortBy,
sortDirection,
);
cols[group.id] = sorted.map((i) => i.id);
}
return cols;
}
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
const idx = ids.indexOf(activeId);
if (idx === -1) return 0;
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
if (idx === 0) return getPos(ids[1]!) - 1;
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
}
/** Find which column contains a given ID (issue or column droppable). */
function findColumn(
columns: Record<string, string[]>,
id: string,
columnIds: Set<string>,
): string | null {
if (columnIds.has(id)) return id;
for (const [columnId, ids] of Object.entries(columns)) {
if (ids.includes(id)) return columnId;
}
return null;
}
function issueMatchesGroup(issue: Issue, group: BoardColumnGroup): boolean {
if (group.status) return issue.status === group.status;
return (
(issue.assignee_type ?? null) === (group.assigneeType ?? null) &&
(issue.assignee_id ?? null) === (group.assigneeId ?? null)
);
}
function getMoveUpdates(
group: BoardColumnGroup,
position: number,
): BoardMoveUpdates {
if (group.status) return { status: group.status, position };
return {
assignee_type: group.assigneeType ?? null,
assignee_id: group.assigneeId ?? null,
position,
};
}
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
const EMPTY_IDS: string[] = [];
export function BoardView({
issues,
@@ -222,6 +119,7 @@ export function BoardView({
childProgressMap = EMPTY_PROGRESS_MAP,
myIssuesScope,
myIssuesFilter,
sort,
projectId,
}: {
issues: Issue[];
@@ -230,18 +128,23 @@ export function BoardView({
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
visibleStatuses: IssueStatus[];
hiddenStatuses: IssueStatus[];
onMoveIssue: (issueId: string, updates: BoardMoveUpdates) => void;
onMoveIssue: (issueId: string, updates: DragMoveUpdates, onSettled?: () => void) => void;
childProgressMap?: Map<string, ChildProgress>;
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
myIssuesScope?: string;
myIssuesFilter?: MyIssuesFilter;
/** Must match the sort the page queried with — embedded in the cache key. */
sort?: IssueSortParam;
/** When set, the per-column "+" pre-fills the project on the create form. */
projectId?: string;
}) {
const { t } = useT("issues");
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const grouping = useViewStore((s) => s.grouping);
const sortBy = useViewStore((s) => s.sortBy);
const sortFieldKey = sortBy === "created_at" ? "created" : sortBy;
const sortLabel = sortBy !== "position"
? t(($) => $.board.ordered_by, { field: t(($) => $.display[`sort_${sortFieldKey}` as keyof typeof $.display]) })
: null;
const { getActorName } = useActorName();
const myIssuesOpts = myIssuesScope
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
@@ -311,21 +214,23 @@ export function BoardView({
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const isDraggingRef = useRef(false);
const isSettlingRef = useRef(false);
const [settleVersion, setSettleVersion] = useState(0);
// --- Local columns state ---
// Between drags: follows TQ via useEffect.
// During drag: local-only, driven by onDragOver/onDragEnd.
const [columns, setColumns] = useState<Record<string, string[]>>(() =>
buildColumns(groupedIssues, groups, grouping, sortBy, sortDirection),
buildColumns(groupedIssues, groups, grouping),
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
useEffect(() => {
if (!isDraggingRef.current) {
setColumns(buildColumns(groupedIssues, groups, grouping, sortBy, sortDirection));
if (!isDraggingRef.current && !isSettlingRef.current) {
setColumns(buildColumns(groupedIssues, groups, grouping));
}
}, [groupedIssues, groups, grouping, sortBy, sortDirection]);
}, [groupedIssues, groups, grouping, settleVersion]);
// After a cross-column move, lock for one animation frame so dnd-kit's
// collision detection can stabilize before processing the next move.
@@ -348,7 +253,7 @@ export function BoardView({
}, [groupedIssues]);
const issueMapRef = useRef(issueMap);
if (!isDraggingRef.current) {
if (!isDraggingRef.current && !isSettlingRef.current) {
issueMapRef.current = issueMap;
}
@@ -380,6 +285,8 @@ export function BoardView({
const overCol = findColumn(prev, overId, groupIds);
if (!activeCol || !overCol || activeCol === overCol) return prev;
if (sortBy !== "position") return prev;
recentlyMovedRef.current = true;
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
const newIds = [...prev[overCol]!];
@@ -389,7 +296,7 @@ export function BoardView({
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
},
[groupIds],
[groupIds, sortBy],
);
const handleDragEnd = useCallback(
@@ -399,7 +306,7 @@ export function BoardView({
setActiveIssue(null);
const resetColumns = () =>
setColumns(buildColumns(groupedIssues, groups, grouping, sortBy, sortDirection));
setColumns(buildColumns(groupedIssues, groups, grouping));
if (!over) {
resetColumns();
@@ -417,9 +324,9 @@ export function BoardView({
return;
}
// Same-column reorder
// Same-column reorder (manual sort only)
let finalColumns = cols;
if (activeCol === overCol) {
if (activeCol === overCol && sortBy === "position") {
const ids = cols[activeCol]!;
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
@@ -430,7 +337,9 @@ export function BoardView({
}
}
const finalCol = findColumn(finalColumns, activeId, groupIds);
const finalCol = sortBy === "position"
? findColumn(finalColumns, activeId, groupIds)
: overCol;
if (!finalCol) {
resetColumns();
return;
@@ -442,6 +351,22 @@ export function BoardView({
}
const map = issueMapRef.current;
if (sortBy !== "position") {
// Cross-column: only update group (status/assignee), keep original position.
const currentIssue = map.get(activeId);
if (!currentIssue || issueMatchesGroup(currentIssue, finalGroup)) {
resetColumns();
return;
}
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), () => {
isSettlingRef.current = false;
setSettleVersion((v) => v + 1);
});
return;
}
const finalIds = finalColumns[finalCol]!;
const newPosition = computePosition(finalIds, activeId, map);
const currentIssue = map.get(activeId);
@@ -454,9 +379,12 @@ export function BoardView({
return;
}
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition));
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), () => {
isSettlingRef.current = false;
});
},
[groupedIssues, groups, grouping, sortBy, sortDirection, onMoveIssue, groupIds, groupMap],
[groupedIssues, groups, grouping, onMoveIssue, groupIds, groupMap, sortBy],
);
return (
@@ -467,7 +395,7 @@ export function BoardView({
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-2">
{groups.length === 0 ? (
<div className="flex min-w-full flex-1 items-center justify-center text-sm text-muted-foreground">
{t(($) => $.board.empty_grouping)}
@@ -478,33 +406,38 @@ export function BoardView({
<PaginatedBoardColumn
key={group.id}
group={group}
issueIds={columns[group.id] ?? []}
issueIds={columns[group.id] ?? EMPTY_IDS}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
myIssuesOpts={myIssuesOpts}
sort={sort}
projectId={projectId}
sortLabel={sortLabel}
/>
) : (
assigneeGroupQueryKey && assigneeGroupFilter ? (
<PaginatedAssigneeBoardColumn
key={group.id}
group={group}
issueIds={columns[group.id] ?? []}
issueIds={columns[group.id] ?? EMPTY_IDS}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
queryKey={assigneeGroupQueryKey}
filter={assigneeGroupFilter}
sort={sort}
projectId={projectId}
sortLabel={sortLabel}
/>
) : (
<BoardColumn
key={group.id}
group={group}
issueIds={columns[group.id] ?? []}
issueIds={columns[group.id] ?? EMPTY_IDS}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
projectId={projectId}
totalCount={group.totalCount}
sortLabel={sortLabel}
/>
)
),
@@ -512,16 +445,17 @@ export function BoardView({
)}
{grouping === "status" && hiddenStatuses.length > 0 && (
<HiddenColumnsPanel
<BoardHiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
myIssuesOpts={myIssuesOpts}
sort={sort}
/>
)}
</div>
<DragOverlay dropAnimation={null}>
{activeIssue ? (
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<div style={{ width: BOARD_CARD_WIDTH }} className="rotate-1 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<BoardCardContent issue={activeIssue} childProgress={childProgressMap.get(activeIssue.id)} />
</div>
) : null}
@@ -530,14 +464,16 @@ export function BoardView({
);
}
function PaginatedAssigneeBoardColumn({
const PaginatedAssigneeBoardColumn = memo(function PaginatedAssigneeBoardColumn({
group,
issueIds,
issueMap,
childProgressMap,
queryKey,
filter,
sort,
projectId,
sortLabel,
}: {
group: BoardColumnGroup;
issueIds: string[];
@@ -545,7 +481,9 @@ function PaginatedAssigneeBoardColumn({
childProgressMap?: Map<string, ChildProgress>;
queryKey: QueryKey;
filter: AssigneeGroupedIssuesFilter;
sort?: IssueSortParam;
projectId?: string;
sortLabel?: string | null;
}) {
const { loadMore, hasMore, isLoading, total } = useLoadMoreByAssigneeGroup(
{
@@ -555,6 +493,7 @@ function PaginatedAssigneeBoardColumn({
},
queryKey,
filter,
sort,
);
return (
<BoardColumn
@@ -564,6 +503,7 @@ function PaginatedAssigneeBoardColumn({
childProgressMap={childProgressMap}
totalCount={total}
projectId={projectId}
sortLabel={sortLabel}
footer={
hasMore ? (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
@@ -571,26 +511,31 @@ function PaginatedAssigneeBoardColumn({
}
/>
);
}
});
function PaginatedBoardColumn({
const PaginatedBoardColumn = memo(function PaginatedBoardColumn({
group,
issueIds,
issueMap,
childProgressMap,
myIssuesOpts,
sort,
projectId,
sortLabel,
}: {
group: BoardColumnGroup & { status: IssueStatus };
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap?: Map<string, ChildProgress>;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
sort?: IssueSortParam;
projectId?: string;
sortLabel?: string | null;
}) {
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
group.status,
myIssuesOpts,
sort,
);
return (
<BoardColumn
@@ -600,6 +545,7 @@ function PaginatedBoardColumn({
childProgressMap={childProgressMap}
totalCount={total}
projectId={projectId}
sortLabel={sortLabel}
footer={
hasMore ? (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
@@ -607,76 +553,48 @@ function PaginatedBoardColumn({
}
/>
);
}
});
function HiddenColumnsPanel({
hiddenStatuses,
myIssuesOpts,
}: {
hiddenStatuses: IssueStatus[];
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
}) {
const { t } = useT("issues");
return (
<div className="flex w-[240px] shrink-0 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<span className="text-sm font-medium text-muted-foreground">
{t(($) => $.board.hidden_columns_label)}
</span>
</div>
<div className="flex-1 space-y-0.5">
{hiddenStatuses.map((status) => (
<HiddenColumnRow
key={status}
status={status}
myIssuesOpts={myIssuesOpts}
/>
))}
</div>
</div>
);
}
function HiddenColumnRow({
/**
* Board-view-specific row that pulls the server-aggregated total from
* `useLoadMoreByStatus` and hands it to the shared {@link HiddenColumnRow}.
* Lives here (not in `hidden-columns-panel.tsx`) so the shared panel stays
* free of `useLoadMoreByStatus` / `myIssuesOpts` coupling — the swimlane
* uses an in-memory total instead.
*/
function BoardHiddenColumnRow({
status,
myIssuesOpts,
sort,
}: {
status: IssueStatus;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
sort?: IssueSortParam;
}) {
const { total } = useLoadMoreByStatus(status, myIssuesOpts, sort);
return <HiddenColumnRow status={status} total={total} />;
}
function BoardHiddenColumnsPanel({
hiddenStatuses,
myIssuesOpts,
sort,
}: {
hiddenStatuses: IssueStatus[];
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
sort?: IssueSortParam;
}) {
const { t } = useT("issues");
const viewStoreApi = useViewStoreApi();
const { total } = useLoadMoreByStatus(status, myIssuesOpts);
return (
<div className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50">
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm">{t(($) => $.status[status])}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{total}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => viewStoreApi.getState().showStatus(status)}
>
<Eye className="size-3.5" />
{t(($) => $.board.show_column)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<HiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
renderRow={(status) => (
<BoardHiddenColumnRow
key={status}
status={status}
myIssuesOpts={myIssuesOpts}
sort={sort}
/>
)}
/>
);
}
@@ -0,0 +1,95 @@
"use client";
import { Eye, MoreHorizontal } from "lucide-react";
import type { IssueStatus } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
import { useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
import { StatusIcon } from "./status-icon";
import { useT } from "../../i18n";
/**
* Single source of truth for the "Hidden columns" side panel rendered by
* the kanban-style views (board and swimlane).
*
* Each consumer renders its own per-row count via the {@link renderRow} slot —
* the board uses `useLoadMoreByStatus` to fetch the workspace-wide aggregate,
* while the swimlane uses an in-memory total derived from already-loaded
* issues. Centralising the chrome here keeps a future view (calendar /
* timeline / etc.) from forking yet another copy.
*/
export function HiddenColumnsPanel({
hiddenStatuses,
renderRow,
}: {
hiddenStatuses: IssueStatus[];
renderRow: (status: IssueStatus) => React.ReactNode;
}) {
const { t } = useT("issues");
return (
<div className="flex w-[240px] shrink-0 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<span className="text-sm font-medium text-muted-foreground">
{t(($) => $.board.hidden_columns_label)}
</span>
</div>
<div className="flex-1 space-y-0.5">
{hiddenStatuses.map((status) => renderRow(status))}
</div>
</div>
);
}
/**
* One row inside {@link HiddenColumnsPanel}. Pure presentational — the caller
* computes `total` however it likes (cached query vs. in-memory aggregate).
*/
export function HiddenColumnRow({
status,
total,
}: {
status: IssueStatus;
total: number;
}) {
const { t } = useT("issues");
const viewStoreApi = useViewStoreApi();
return (
<div className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50">
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm">{t(($) => $.status[status])}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{total}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t(($) => $.board.show_column)}
className="rounded-full text-muted-foreground"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => viewStoreApi.getState().showStatus(status)}
>
<Eye className="size-3.5" />
{t(($) => $.board.show_column)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { memo, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
HoverCard,
@@ -46,7 +46,7 @@ interface IssueAgentActivityIndicatorProps {
* Re-renders on every snapshot invalidation (WS task:* events drive it
* via use-realtime-sync). 30s staleTime is the offline fallback only.
*/
export function IssueAgentActivityIndicator({
export const IssueAgentActivityIndicator = memo(function IssueAgentActivityIndicator({
issueId,
size = 12,
}: IssueAgentActivityIndicatorProps) {
@@ -120,4 +120,4 @@ export function IssueAgentActivityIndicator({
</HoverCardContent>
</HoverCard>
);
}
});
@@ -14,11 +14,12 @@ import {
FolderMinus,
List,
SignalHigh,
SlidersHorizontal,
X,
Tag,
User,
UserMinus,
UserPen,
Waves,
} from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
@@ -28,6 +29,8 @@ import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
@@ -60,6 +63,7 @@ import {
type ActorFilterValue,
} from "@multica/core/issues/stores/view-store";
import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
import type { SortField, IssueGrouping, ViewMode } from "@multica/core/issues/stores/view-store";
import {
useIssuesScopeStore,
type IssuesScope,
@@ -603,17 +607,17 @@ export function IssueDisplayControls({
const counts = useIssueCounts(scopedIssues);
const hasActiveFilters =
getActiveFilterCount({
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
projectFilters,
includeNoProject,
labelFilters,
}) > 0;
const activeFilterCount = getActiveFilterCount({
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
projectFilters,
includeNoProject,
labelFilters,
});
const hasActiveFilters = activeFilterCount > 0;
const SORT_LABEL_KEY: Record<typeof SORT_OPTIONS[number]["value"], "sort_manual" | "sort_priority" | "sort_start_date" | "sort_due_date" | "sort_created" | "sort_title"> = {
position: "sort_manual",
@@ -649,10 +653,29 @@ export function IssueDisplayControls({
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="relative text-muted-foreground">
<Filter className="size-4" />
<Button
variant={hasActiveFilters ? "default" : "outline"}
size="sm"
className={
hasActiveFilters
? "gap-1 bg-brand text-white hover:bg-brand/90"
: "gap-1 text-muted-foreground"
}
>
<Filter className="size-3.5" />
{hasActiveFilters
? t(($) => $.filters.active_count, { count: activeFilterCount })
: t(($) => $.filters.tooltip)}
{hasActiveFilters && (
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
<span
role="button"
tabIndex={-1}
className="-mr-1 ml-0.5 rounded-sm p-0.5 hover:bg-white/20"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); act.clearFilters(); }}
onPointerDown={(e) => e.stopPropagation()}
>
<X className="size-3" />
</span>
)}
</Button>
}
@@ -841,8 +864,13 @@ export function IssueDisplayControls({
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
<SlidersHorizontal className="size-4" />
<Button variant="outline" size="sm" className="gap-1 text-muted-foreground">
{sortBy !== "position" && (
sortDirection === "asc"
? <ArrowUp className="size-3.5" />
: <ArrowDown className="size-3.5" />
)}
{sortLabel}
</Button>
}
/>
@@ -871,14 +899,13 @@ export function IssueDisplayControls({
}
/>
<DropdownMenuContent align="start" className="w-auto">
{GROUPING_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setGrouping(opt.value)}
>
{t(($) => $.display[GROUPING_LABEL_KEY[opt.value]])}
</DropdownMenuItem>
))}
<DropdownMenuRadioGroup value={grouping} onValueChange={(v) => act.setGrouping(v as IssueGrouping)}>
{GROUPING_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{t(($) => $.display[GROUPING_LABEL_KEY[opt.value]])}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -904,30 +931,31 @@ export function IssueDisplayControls({
}
/>
<DropdownMenuContent align="start" className="w-auto">
{SORT_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setSortBy(opt.value)}
>
{t(($) => $.display[SORT_LABEL_KEY[opt.value]])}
</DropdownMenuItem>
))}
<DropdownMenuRadioGroup value={sortBy} onValueChange={(v) => act.setSortBy(v as SortField)}>
{SORT_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{t(($) => $.display[SORT_LABEL_KEY[opt.value]])}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
act.setSortDirection(sortDirection === "asc" ? "desc" : "asc")
}
title={sortDirection === "asc" ? t(($) => $.display.ascending_title) : t(($) => $.display.descending_title)}
>
{sortDirection === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)}
</Button>
{sortBy !== "position" && (
<Button
variant="outline"
size="icon-sm"
onClick={() =>
act.setSortDirection(sortDirection === "asc" ? "desc" : "asc")
}
title={sortDirection === "asc" ? t(($) => $.display.ascending_title) : t(($) => $.display.descending_title)}
>
{sortDirection === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)}
</Button>
)}
</div>
</div>
@@ -964,14 +992,23 @@ export function IssueDisplayControls({
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
<Button variant="outline" size="sm" className="gap-1 text-muted-foreground">
{viewMode === "board" ? (
<Columns3 className="size-4" />
<Columns3 className="size-3.5" />
) : viewMode === "swimlane" ? (
<Waves className="size-3.5" />
) : viewMode === "gantt" && allowGantt ? (
<ChartGantt className="size-4" />
<ChartGantt className="size-3.5" />
) : (
<List className="size-4" />
<List className="size-3.5" />
)}
{viewMode === "board"
? t(($) => $.view.board)
: viewMode === "swimlane"
? t(($) => $.view.swimlane)
: viewMode === "gantt" && allowGantt
? t(($) => $.view.gantt)
: t(($) => $.view.list)}
</Button>
}
/>
@@ -980,6 +1017,8 @@ export function IssueDisplayControls({
<TooltipContent side="bottom">
{viewMode === "board"
? t(($) => $.view.tooltip_board)
: viewMode === "swimlane"
? t(($) => $.view.tooltip_swimlane)
: viewMode === "gantt" && allowGantt
? t(($) => $.view.tooltip_gantt)
: t(($) => $.view.tooltip_list)}
@@ -988,21 +1027,27 @@ export function IssueDisplayControls({
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>{t(($) => $.view.section)}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
</DropdownMenuGroup>
<DropdownMenuRadioGroup value={viewMode} onValueChange={(v) => act.setViewMode(v as ViewMode)}>
<DropdownMenuRadioItem value="board">
<Columns3 />
{t(($) => $.view.board)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="list">
<List />
{t(($) => $.view.list)}
</DropdownMenuItem>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="swimlane">
<Waves />
{t(($) => $.view.swimlane)}
</DropdownMenuRadioItem>
{allowGantt && (
<DropdownMenuItem onClick={() => act.setViewMode("gantt")}>
<DropdownMenuRadioItem value="gantt">
<ChartGantt />
{t(($) => $.view.gantt)}
</DropdownMenuItem>
</DropdownMenuRadioItem>
)}
</DropdownMenuGroup>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -22,9 +22,13 @@ import { PageHeader } from "../../layout/page-header";
import { IssuesHeader } from "./issues-header";
import { BoardView } from "./board-view";
import { ListView } from "./list-view";
import { SwimLaneView } from "./swimlane-view";
import { BatchActionToolbar } from "./batch-action-toolbar";
import type { ChildProgress } from "./list-row";
import { useT } from "../../i18n";
const EMPTY_CHILD_PROGRESS = new Map<string, ChildProgress>();
export function IssuesPage() {
const { t } = useT("issues");
const wsId = useWorkspaceId();
@@ -41,9 +45,19 @@ export function IssuesPage() {
const projectFilters = useIssueViewStore((s) => s.projectFilters);
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
const labelFilters = useIssueViewStore((s) => s.labelFilters);
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
const agentRunningFilter = useIssueViewStore((s) => s.agentRunningFilter);
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
const sort = useMemo(
() => ({
sort_by: sortBy,
sort_direction: sortBy !== "position" ? sortDirection : undefined,
} as const),
[sortBy, sortDirection],
);
// Derive the set of issue ids that currently have at least one
// `running` agent task. Used by the workspace agents-working filter
// chip. Subscribing the page here (not deep in filter.ts) keeps the
@@ -75,9 +89,9 @@ export function IssuesPage() {
return filter;
}, [assigneeFilters, creatorFilters, includeNoAssignee, includeNoProject, labelFilters, priorityFilters, projectFilters, scope, statusFilters]);
const assigneeGroupsOptions = issueAssigneeGroupsOptions(wsId, assigneeGroupFilter);
const assigneeGroupsOptions = issueAssigneeGroupsOptions(wsId, assigneeGroupFilter, sort);
const statusIssuesQuery = useQuery({
...issueListOptions(wsId),
...issueListOptions(wsId, sort),
enabled: !usesAssigneeBoard,
});
const assigneeGroupsQuery = useQuery({
@@ -119,9 +133,16 @@ export function IssuesPage() {
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, agentRunningFilter, runningIssueIds],
);
// Status-unfiltered companion for Swimlane — same narrowing as `issues`
// minus the status filter.
const swimlaneIssues = useMemo(
() => filterIssues(scopedIssues, { statusFilters: [], priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, agentRunningFilter, runningIssueIds }),
[scopedIssues, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, agentRunningFilter, runningIssueIds],
);
// Fetch sub-issue progress from the backend so counts are accurate
// regardless of client-side pagination or filtering of done issues.
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
const { data: childProgressMap = EMPTY_CHILD_PROGRESS } = useQuery(childIssueProgressOptions(wsId));
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0)
@@ -135,7 +156,7 @@ export function IssuesPage() {
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position" | "parent_issue_id">, onSettled?: () => void) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{
@@ -145,55 +166,33 @@ export function IssuesPage() {
? err.message
: t(($) => $.page.move_failed),
),
onSettled: () => onSettled?.(),
},
);
},
[updateIssueMutation, t],
);
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
const contentSkeleton = viewMode === "list" ? (
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : (
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
<div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-14 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
<Skeleton className="h-8 w-16 rounded-md" />
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
</div>
{viewMode === "list" ? (
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : (
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
)}
</div>
);
}
))}
</div>
);
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header 1: Workspace breadcrumb */}
<PageHeader className="gap-1.5">
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
<span className="text-sm text-muted-foreground">
@@ -204,11 +203,9 @@ export function IssuesPage() {
</PageHeader>
<ViewStoreProvider store={useIssueViewStore}>
{/* Header 2: Scope tabs + filters */}
<IssuesHeader scopedIssues={headerIssues} />
{/* Content: scrollable */}
{headerIssues.length === 0 ? (
{loading ? contentSkeleton : headerIssues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">{t(($) => $.page.empty_title)}</p>
@@ -226,9 +223,20 @@ export function IssuesPage() {
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
sort={sort}
/>
) : viewMode === "swimlane" ? (
<SwimLaneView
issues={issues}
unfilteredIssues={swimlaneIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
sort={sort}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} />
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} sort={sort} onMoveIssue={handleMoveIssue} />
)}
</div>
)}
+85 -5
View File
@@ -1,7 +1,10 @@
"use client";
import { memo } from "react";
import { memo, type Ref } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { AppLink } from "../../navigation";
import type { Issue } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
@@ -29,12 +32,22 @@ function formatDate(date: string): string {
});
}
export const ListRow = memo(function ListRow({
function ListRowContent({
issue,
childProgress,
isDragging,
containerRef,
containerStyle,
containerProps,
checkboxProps,
}: {
issue: Issue;
childProgress?: ChildProgress;
isDragging?: boolean;
containerRef?: Ref<HTMLDivElement>;
containerStyle?: React.CSSProperties;
containerProps?: Record<string, unknown>;
checkboxProps?: Pick<React.HTMLAttributes<HTMLDivElement>, "onClick" | "onMouseDown" | "onPointerDown">;
}) {
const selected = useIssueSelectionStore((s) => s.selectedIds.has(issue.id));
const toggle = useIssueSelectionStore((s) => s.toggle);
@@ -58,11 +71,17 @@ export const ListRow = memo(function ListRow({
return (
<IssueActionsContextMenu issue={issue}>
<div
ref={containerRef}
style={containerStyle}
{...containerProps}
className={`group/row flex h-9 items-center gap-2 px-4 text-sm transition-colors hover:not-data-[popup-open]:bg-accent/60 data-[popup-open]:bg-accent ${
selected ? "bg-accent/30" : ""
}`}
} ${isDragging ? "opacity-30" : ""}`}
>
<div className="relative flex shrink-0 items-center justify-center w-4 h-4">
<div
className="relative flex shrink-0 items-center justify-center w-4 h-4"
{...checkboxProps}
>
<PriorityIcon
priority={issue.priority}
className={selected ? "hidden" : "group-hover/row:hidden"}
@@ -78,7 +97,7 @@ export const ListRow = memo(function ListRow({
</div>
<AppLink
href={p.issueDetail(issue.id)}
className="flex flex-1 items-center gap-2 min-w-0"
className={`flex flex-1 items-center gap-2 min-w-0 ${isDragging ? "pointer-events-none" : ""}`}
>
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{issue.identifier}
@@ -136,4 +155,65 @@ export const ListRow = memo(function ListRow({
</div>
</IssueActionsContextMenu>
);
}
export const ListRow = memo(function ListRow({
issue,
childProgress,
}: {
issue: Issue;
childProgress?: ChildProgress;
}) {
return <ListRowContent issue={issue} childProgress={childProgress} />;
});
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
const { isSorting, wasDragging } = args;
if (isSorting || wasDragging) return false;
return defaultAnimateLayoutChanges(args);
};
const stopDrag = (e: React.SyntheticEvent) => {
e.stopPropagation();
};
export const DraggableListRow = memo(function DraggableListRow({
issue,
childProgress,
disableSorting,
}: {
issue: Issue;
childProgress?: ChildProgress;
disableSorting?: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: issue.id,
data: { status: issue.status },
animateLayoutChanges,
disabled: disableSorting ? { droppable: true } : undefined,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<ListRowContent
issue={issue}
childProgress={childProgress}
isDragging={isDragging}
containerRef={setNodeRef}
containerStyle={style}
containerProps={{ ...attributes, ...listeners }}
checkboxProps={{ onClick: stopDrag, onMouseDown: stopDrag, onPointerDown: stopDrag }}
/>
);
});
+350 -50
View File
@@ -1,23 +1,55 @@
"use client";
import { useMemo } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { ChevronRight, Plus } from "lucide-react";
import { Accordion } from "@base-ui/react/accordion";
import {
DndContext,
DragOverlay,
PointerSensor,
useDroppable,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
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 type { IssueSortParam, MyIssuesFilter } from "@multica/core/issues/queries";
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 { StatusHeading } from "./status-heading";
import { ListRow, type ChildProgress } from "./list-row";
import { ListRow, DraggableListRow, type ChildProgress } from "./list-row";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
import { useT } from "../../i18n";
import {
type DragMoveUpdates,
makeKanbanCollision,
statusGroupId,
buildColumns,
computePosition,
findColumn,
issueMatchesGroup,
getMoveUpdates,
} from "../utils/drag-utils";
import type { BoardColumnGroup } from "./board-column";
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
const EMPTY_IDS: string[] = [];
function buildListGroups(visibleStatuses: IssueStatus[]): BoardColumnGroup[] {
return visibleStatuses.map((status) => ({
id: statusGroupId(status),
title: status,
status,
createData: { status },
}));
}
export function ListView({
issues,
@@ -26,33 +58,31 @@ export function ListView({
myIssuesScope,
myIssuesFilter,
projectId,
onMoveIssue,
sort,
}: {
issues: Issue[];
visibleStatuses: IssueStatus[];
childProgressMap?: Map<string, ChildProgress>;
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
myIssuesScope?: string;
myIssuesFilter?: MyIssuesFilter;
/** When set, the per-section "+" pre-fills the project on the create form. */
projectId?: string;
onMoveIssue?: (issueId: string, updates: DragMoveUpdates, onSettled?: () => void) => void;
sort?: IssueSortParam;
}) {
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const listCollapsedStatuses = useViewStore(
(s) => s.listCollapsedStatuses
);
const toggleListCollapsed = useViewStore(
(s) => s.toggleListCollapsed
);
const sortBy = useViewStore((s) => s.sortBy);
const { t } = useT("issues");
const issuesByStatus = useMemo(() => {
const map = new Map<IssueStatus, Issue[]>();
for (const status of visibleStatuses) {
const filtered = issues.filter((i) => i.status === status);
map.set(status, sortIssues(filtered, sortBy, sortDirection));
}
return map;
}, [issues, visibleStatuses, sortBy, sortDirection]);
const sortFieldKey = sortBy === "created_at" ? "created" : sortBy;
const sortLabel = sortBy !== "position"
? t(($) => $.board.ordered_by, { field: t(($) => $.display[`sort_${sortFieldKey}` as keyof typeof $.display]) })
: null;
const expandedStatuses = useMemo(
() =>
@@ -66,49 +96,282 @@ export function ListView({
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
: undefined;
return (
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
<Accordion.Root
multiple
className="space-y-1"
value={expandedStatuses}
onValueChange={(value: string[]) => {
for (const status of visibleStatuses) {
const wasExpanded = expandedStatuses.includes(status);
const isExpanded = value.includes(status);
if (wasExpanded !== isExpanded) {
toggleListCollapsed(status as IssueStatus);
}
const dragEnabled = !!onMoveIssue;
const groups = useMemo(
() => buildListGroups(visibleStatuses),
[visibleStatuses],
);
const groupIds = useMemo(
() => new Set(groups.map((g) => g.id)),
[groups],
);
const groupMap = useMemo(
() => new Map(groups.map((g) => [g.id, g])),
[groups],
);
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const isDraggingRef = useRef(false);
const isSettlingRef = useRef(false);
const [settleVersion, setSettleVersion] = useState(0);
const [columns, setColumns] = useState<Record<string, string[]>>(() =>
buildColumns(issues, groups, "status"),
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
useEffect(() => {
if (!isDraggingRef.current && !isSettlingRef.current) {
setColumns(buildColumns(issues, groups, "status"));
}
}, [issues, groups, settleVersion]);
const recentlyMovedRef = useRef(false);
useEffect(() => {
const id = requestAnimationFrame(() => {
recentlyMovedRef.current = false;
});
return () => cancelAnimationFrame(id);
}, [columns]);
const issueMap = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const issueMapRef = useRef(issueMap);
if (!isDraggingRef.current && !isSettlingRef.current) {
issueMapRef.current = issueMap;
}
const collisionDetection = useMemo(
() => makeKanbanCollision(groupIds),
[groupIds],
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
})
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
isDraggingRef.current = true;
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
setActiveIssue(issue);
},
[],
);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over || recentlyMovedRef.current) return;
const activeId = active.id as string;
const overId = over.id as string;
setColumns((prev) => {
const activeCol = findColumn(prev, activeId, groupIds);
const overCol = findColumn(prev, overId, groupIds);
if (!activeCol || !overCol || activeCol === overCol) return prev;
if (sortBy !== "position") return prev;
recentlyMovedRef.current = true;
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
const newIds = [...prev[overCol]!];
const overIndex = newIds.indexOf(overId);
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
newIds.splice(insertIndex, 0, activeId);
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
},
[groupIds, sortBy],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
isDraggingRef.current = false;
setActiveIssue(null);
const resetColumns = () =>
setColumns(buildColumns(issues, groups, "status"));
if (!over || !onMoveIssue) {
resetColumns();
return;
}
const activeId = active.id as string;
const overId = over.id as string;
const cols = columnsRef.current;
const activeCol = findColumn(cols, activeId, groupIds);
const overCol = findColumn(cols, overId, groupIds);
if (!activeCol || !overCol) {
resetColumns();
return;
}
let finalColumns = cols;
if (activeCol === overCol && sortBy === "position") {
const ids = cols[activeCol]!;
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const reordered = arrayMove(ids, oldIndex, newIndex);
finalColumns = { ...cols, [activeCol]: reordered };
setColumns(finalColumns);
}
}
const finalCol = sortBy === "position"
? findColumn(finalColumns, activeId, groupIds)
: overCol;
if (!finalCol) {
resetColumns();
return;
}
const finalGroup = groupMap.get(finalCol);
if (!finalGroup) {
resetColumns();
return;
}
const map = issueMapRef.current;
if (sortBy !== "position") {
const currentIssue = map.get(activeId);
if (!currentIssue || issueMatchesGroup(currentIssue, finalGroup)) {
resetColumns();
return;
}
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), () => {
isSettlingRef.current = false;
setSettleVersion((v) => v + 1);
});
return;
}
const finalIds = finalColumns[finalCol]!;
const newPosition = computePosition(finalIds, activeId, map);
const currentIssue = map.get(activeId);
if (
currentIssue &&
issueMatchesGroup(currentIssue, finalGroup) &&
currentIssue.position === newPosition
) {
return;
}
isSettlingRef.current = true;
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition), () => {
isSettlingRef.current = false;
});
},
[issues, groups, onMoveIssue, groupIds, groupMap, sortBy],
);
const content = (
<Accordion.Root
multiple
className="space-y-1"
value={expandedStatuses}
onValueChange={(value: string[]) => {
if (isDraggingRef.current) return;
for (const status of visibleStatuses) {
const wasExpanded = expandedStatuses.includes(status);
const isExpanded = value.includes(status);
if (wasExpanded !== isExpanded) {
toggleListCollapsed(status as IssueStatus);
}
}}
>
{visibleStatuses.map((status) => (
}
}}
>
{visibleStatuses.map((status) => {
const isExpanded = expandedStatuses.includes(status);
return (
<StatusAccordionItem
key={status}
status={status}
issues={issuesByStatus.get(status) ?? []}
issueIds={columns[statusGroupId(status)] ?? EMPTY_IDS}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
myIssuesOpts={myIssuesOpts}
projectId={projectId}
dragEnabled={dragEnabled}
isExpanded={isExpanded}
sortLabel={sortLabel}
sort={sort}
/>
))}
</Accordion.Root>
</div>
);
})}
</Accordion.Root>
);
if (!dragEnabled) {
return (
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
{content}
</div>
);
}
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex-1 min-h-0 overflow-y-auto p-2 pt-0">
{content}
</div>
<DragOverlay dropAnimation={null}>
{activeIssue ? (
<div className="max-w-2xl rotate-1 cursor-grabbing opacity-90 shadow-lg shadow-black/10 rounded-md border border-border bg-card px-4 py-2">
<span className="text-xs text-muted-foreground mr-2">{activeIssue.identifier}</span>
<span className="text-sm">{activeIssue.title}</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
function StatusAccordionItem({
status,
issues,
issueIds,
issueMap,
childProgressMap,
myIssuesOpts,
projectId,
dragEnabled,
isExpanded,
sortLabel,
sort,
}: {
status: IssueStatus;
issues: Issue[];
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap: Map<string, ChildProgress>;
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
projectId?: string;
dragEnabled: boolean;
isExpanded: boolean;
sortLabel: string | null;
sort?: IssueSortParam;
}) {
const { t } = useT("issues");
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
@@ -117,16 +380,37 @@ function StatusAccordionItem({
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
status,
myIssuesOpts,
sort,
);
const issues = useMemo(
() => issueIds.flatMap((id) => {
const issue = issueMap.get(id);
return issue ? [issue] : [];
}),
[issueIds, issueMap],
);
const issueIds = issues.map((i) => i.id);
const selectedCount = issueIds.filter((id) => selectedIds.has(id)).length;
const allSelected = issues.length > 0 && selectedCount === issues.length;
const someSelected = selectedCount > 0;
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: statusGroupId(status),
disabled: !dragEnabled,
});
const disableSorting = !!sortLabel;
return (
<Accordion.Item value={status}>
<Accordion.Header className="group/header sticky top-0 z-10 flex h-10 items-center rounded-lg bg-muted transition-colors hover:bg-accent">
<Accordion.Item value={status} ref={dragEnabled ? setDroppableRef : undefined}>
<Accordion.Header
className={`group/header sticky top-0 z-10 flex h-10 items-center rounded-lg bg-muted transition-colors hover:bg-accent ${
isOver && !isExpanded
? "ring-2 ring-brand/25 bg-accent/15"
: ""
}`}
>
<div className="pl-3 flex items-center">
<input
type="checkbox"
@@ -170,16 +454,32 @@ function StatusAccordionItem({
</Tooltip>
</div>
</Accordion.Header>
<Accordion.Panel className="pt-1">
<Accordion.Panel>
{issues.length > 0 ? (
<>
{issues.map((issue) => (
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
))}
{hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
)}
</>
dragEnabled ? (
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{issues.map((issue) => (
<DraggableListRow
key={issue.id}
issue={issue}
childProgress={childProgressMap.get(issue.id)}
disableSorting={disableSorting}
/>
))}
{hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
)}
</SortableContext>
) : (
<>
{issues.map((issue) => (
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
))}
{hasMore && (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
)}
</>
)
) : (
<p className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.list.empty_status)}
@@ -0,0 +1,890 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SwimLaneView } from "./swimlane-view";
import type { Issue } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enIssues from "../../locales/en/issues.json";
const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } };
// Mock hooks
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
// Mock paths
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
useWorkspaceSlug: () => "acme",
useRequiredWorkspaceSlug: () => "acme",
useWorkspacePaths: () => actual.paths.workspace("acme"),
};
});
// Mock @multica/core/auth
const mockAuthUser = { id: "user-1", email: "[email protected]", name: "Test User" };
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector?: any) => {
const state = { user: mockAuthUser, isAuthenticated: true };
return selector ? selector(state) : state;
},
{ getState: () => ({ user: mockAuthUser, isAuthenticated: true }) },
),
registerAuthStore: vi.fn(),
createAuthStore: vi.fn(),
}));
// Mock navigation
vi.mock("../../navigation", () => ({
AppLink: ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
useNavigation: () => ({ push: vi.fn(), pathname: "/issues" }),
NavigationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock issue config
vi.mock("@multica/core/issues/config", () => ({
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
STATUS_CONFIG: {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
},
PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"],
PRIORITY_CONFIG: {
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
high: { label: "High", bars: 3, color: "text-warning" },
medium: { label: "Medium", bars: 2, color: "text-warning" },
low: { label: "Low", bars: 1, color: "text-info" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
},
}));
// Default mock returns hasMore=false so the load-more sentinels render
// as no-op divs and don't pull IntersectionObserver into JSDOM.
const mockLoadMore = vi.fn();
const useLoadMoreByStatusMock = vi.fn(
(_status: string, _opts?: unknown, _sort?: unknown) => ({
total: 0,
loaded: 0,
hasMore: false,
isLoading: false,
loadMore: mockLoadMore,
}),
);
vi.mock("@multica/core/issues/mutations", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/issues/mutations")>();
return {
...actual,
useLoadMoreByStatus: (status: string, opts?: unknown, sort?: unknown) =>
useLoadMoreByStatusMock(status, opts, sort),
};
});
// Mock view store. `swimlaneOrder` is mutable on the captured object so
// tests can simulate persisted lane order and assert that
// `setSwimlaneOrder` was called by drag-end handlers.
const mockViewState: {
sortBy: "position";
sortDirection: "asc";
cardProperties: Record<string, boolean>;
swimlaneOrder: string[];
collapsedSwimlanes: string[];
setSwimlaneOrder: (order: string[]) => void;
toggleSwimlaneCollapsed: (key: string) => void;
hideStatus: (s: string) => void;
showStatus: (s: string) => void;
} = {
sortBy: "position",
sortDirection: "asc",
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
swimlaneOrder: [],
collapsedSwimlanes: [],
setSwimlaneOrder: vi.fn(),
toggleSwimlaneCollapsed: vi.fn(),
hideStatus: vi.fn(),
showStatus: vi.fn(),
};
const mockSetSwimlaneOrder = mockViewState.setSwimlaneOrder as ReturnType<typeof vi.fn>;
const mockToggleSwimlaneCollapsed = mockViewState.toggleSwimlaneCollapsed as ReturnType<typeof vi.fn>;
vi.mock("@multica/core/issues/stores/view-store-context", () => ({
ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children,
useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState),
useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }),
}));
// Mock modal store
const mockOpenModal = vi.fn();
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
() => ({ open: mockOpenModal }),
{ getState: () => ({ open: mockOpenModal }) },
),
}));
// Mock dnd-kit
let lastOnDragEnd: any = null;
let lastOnDragOver: any = null;
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children, onDragEnd, onDragOver }: any) => {
lastOnDragEnd = onDragEnd;
lastOnDragOver = onDragOver;
return children;
},
DragOverlay: () => null,
PointerSensor: class {},
useSensor: () => ({}),
useSensors: () => [],
useDroppable: () => ({ setNodeRef: vi.fn(), isOver: false }),
pointerWithin: vi.fn(),
closestCenter: vi.fn(),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: any) => children,
verticalListSortingStrategy: {},
// Real arrayMove implementation — the production code uses this both for
// card reordering and lane reordering, so returning undefined would break
// every reorder assertion.
arrayMove: <T,>(arr: T[], from: number, to: number): T[] => {
const copy = arr.slice();
const [item] = copy.splice(from, 1);
copy.splice(to, 0, item!);
return copy;
},
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
vi.mock("@dnd-kit/utilities", () => ({
CSS: { Transform: { toString: () => undefined } },
}));
const mockIssues: Issue[] = [
{
id: "parent-1",
workspace_id: "ws-1",
number: 1,
identifier: "PROJ-1",
title: "Parent Issue 1",
description: "Parent description",
status: "todo",
priority: "high",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 100,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
id: "child-1",
workspace_id: "ws-1",
number: 2,
identifier: "PROJ-2",
title: "Child Issue 1",
description: "Child description",
status: "in_progress",
priority: "medium",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
parent_issue_id: "parent-1",
project_id: null,
position: 200,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
id: "orphan-1",
workspace_id: "ws-1",
number: 3,
identifier: "PROJ-3",
title: "Orphan Issue 1",
description: "No parent",
status: "backlog",
priority: "low",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 300,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
function renderWithI18n(ui: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<I18nProvider resources={TEST_RESOURCES} locale="en">
{ui}
</I18nProvider>
</QueryClientProvider>,
);
}
describe("SwimLaneView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockViewState.swimlaneOrder = [];
mockViewState.collapsedSwimlanes = [];
useLoadMoreByStatusMock.mockImplementation(() => ({
total: 0,
loaded: 0,
hasMore: false,
isLoading: false,
loadMore: mockLoadMore,
}));
});
it("renders status columns as headers", () => {
renderWithI18n(
<SwimLaneView
issues={mockIssues}
onMoveIssue={vi.fn()}
/>,
);
expect(screen.getByText("Backlog")).toBeInTheDocument();
expect(screen.getByText("Todo")).toBeInTheDocument();
expect(screen.getByText("In Progress")).toBeInTheDocument();
});
it("renders parent swimlanes and orphans section", () => {
renderWithI18n(
<SwimLaneView
issues={mockIssues}
onMoveIssue={vi.fn()}
/>,
);
expect(screen.getAllByText("No parent").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Parent Issue 1").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("PROJ-1").length).toBeGreaterThanOrEqual(1);
});
it("renders cards in their corresponding cell", () => {
renderWithI18n(
<SwimLaneView
issues={mockIssues}
onMoveIssue={vi.fn()}
/>,
);
expect(screen.getByText("Orphan Issue 1")).toBeInTheDocument();
// parent-1 is promoted to a lane header — must not also appear as a card.
const parentTitleMatches = screen.getAllByText("Parent Issue 1");
expect(parentTitleMatches).toHaveLength(1);
expect(parentTitleMatches[0]!.closest("div")?.textContent).toContain("PROJ-1");
expect(screen.getByText("Child Issue 1")).toBeInTheDocument();
});
it("triggers modal open when add button is clicked", () => {
renderWithI18n(
<SwimLaneView
issues={mockIssues}
onMoveIssue={vi.fn()}
/>,
);
const addButtons = screen.getAllByRole("button", { name: /add issue/i });
expect(addButtons.length).toBeGreaterThan(0);
fireEvent.click(addButtons[0]!);
expect(mockOpenModal).toHaveBeenCalledWith("create-issue", expect.any(Object));
});
it("includes project_id in the create payload when projectId prop is set", () => {
renderWithI18n(
<SwimLaneView
issues={mockIssues}
onMoveIssue={vi.fn()}
projectId="proj-42"
/>,
);
const addButtons = screen.getAllByRole("button", { name: /add issue/i });
fireEvent.click(addButtons[0]!);
expect(mockOpenModal).toHaveBeenCalledWith(
"create-issue",
expect.objectContaining({ project_id: "proj-42" }),
);
});
// A child whose parent isn't in the loaded set — lands in "Other parents".
const orphanChild: Issue = {
id: "lonely-child",
workspace_id: "ws-1",
number: 99,
identifier: "PROJ-99",
title: "Lonely Child",
description: null,
status: "todo",
priority: "medium",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
parent_issue_id: "missing-parent",
project_id: null,
position: 400,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
it("renders children whose parent is not in the loaded set under an 'Other parents' fallback lane", () => {
renderWithI18n(
<SwimLaneView issues={[orphanChild]} onMoveIssue={vi.fn()} />,
);
expect(screen.getByText("Other parents")).toBeInTheDocument();
expect(screen.getByText("Lonely Child")).toBeInTheDocument();
});
it("does not render the add-issue button inside 'Other parents' cells", () => {
renderWithI18n(
<SwimLaneView
issues={[...mockIssues, orphanChild]}
onMoveIssue={vi.fn()}
/>,
);
// No parent + Parent Issue 1 each have one + per visible status column.
// The Other parents lane must add zero.
const realLaneCount = 2;
const visibleStatusCount = 6; // BOARD_STATUSES default
expect(
screen.getAllByRole("button", { name: /add issue/i }).length,
).toBe(realLaneCount * visibleStatusCount);
});
it("does not call onMoveIssue when a card is dropped onto the empty whitespace of an 'Other parents' cell", () => {
// `over.id` is the orphan cell id — what dnd-kit emits when the
// pointer lands on the cell's empty area.
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView
issues={[...mockIssues, orphanChild]}
onMoveIssue={mockOnMoveIssue}
/>,
);
act(() => {
lastOnDragOver({
active: { id: "child-1" },
over: { id: "swim:parent:__orphans__:todo" },
});
});
act(() => {
lastOnDragEnd({
active: { id: "child-1" },
over: { id: "swim:parent:__orphans__:todo" },
});
});
expect(mockOnMoveIssue).not.toHaveBeenCalled();
});
it("hides a parent lane when all its children are in hidden statuses", () => {
// child-1 (in_progress) is not in the visible set — parent-1's lane
// is dropped. parent-1 itself falls into the No-parent lane as a card.
renderWithI18n(
<SwimLaneView
issues={mockIssues.filter((i) => i.status === "todo")}
unfilteredIssues={mockIssues}
visibleStatuses={["todo"]}
hiddenStatuses={["backlog", "in_progress", "in_review", "done", "blocked"]}
onMoveIssue={vi.fn()}
/>,
);
expect(screen.queryByText("Child Issue 1")).not.toBeInTheDocument();
expect(screen.getAllByText("Parent Issue 1")).toHaveLength(1);
});
it("does not call onMoveIssue when a card is dragged out of 'Other parents'", () => {
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView
issues={[...mockIssues, orphanChild]}
onMoveIssue={mockOnMoveIssue}
/>,
);
act(() => {
lastOnDragEnd({
active: { id: "lonely-child" },
over: { id: "swim:parent:parent-1:in_progress" },
});
});
expect(mockOnMoveIssue).not.toHaveBeenCalled();
});
it("renders an open-parent link for lanes with a real parent", () => {
renderWithI18n(
<SwimLaneView
issues={mockIssues}
onMoveIssue={vi.fn()}
/>,
);
const links = screen.getAllByRole("link", { name: "Open parent issue" });
expect(links).toHaveLength(1);
expect(links[0]).toHaveAttribute("href", expect.stringContaining("parent-1"));
});
it("renders HiddenColumnsPanel only when hiddenStatuses is non-empty", () => {
const { unmount } = renderWithI18n(
<SwimLaneView
issues={mockIssues}
onMoveIssue={vi.fn()}
/>,
);
expect(screen.queryByText("Hidden columns")).not.toBeInTheDocument();
unmount();
renderWithI18n(
<SwimLaneView
issues={mockIssues}
visibleStatuses={["backlog", "todo", "in_progress", "in_review", "done"]}
hiddenStatuses={["blocked"]}
onMoveIssue={vi.fn()}
/>,
);
expect(screen.getByText("Hidden columns")).toBeInTheDocument();
expect(screen.getByText("Blocked")).toBeInTheDocument();
});
it("calls onMoveIssue on drag-and-drop end", () => {
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView
issues={mockIssues}
onMoveIssue={mockOnMoveIssue}
/>,
);
const targetCellId = "swim:parent:none:in_progress";
act(() => {
lastOnDragOver({
active: { id: "orphan-1" },
over: { id: targetCellId },
});
});
act(() => {
lastOnDragEnd({
active: { id: "orphan-1" },
over: { id: targetCellId },
});
});
expect(mockOnMoveIssue).toHaveBeenCalledWith("orphan-1", {
parent_issue_id: null,
status: "in_progress",
position: 300,
});
});
it("does not call onMoveIssue when drop target equals source cell (no-op)", () => {
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView issues={mockIssues} onMoveIssue={mockOnMoveIssue} />,
);
act(() => {
lastOnDragEnd({
active: { id: "parent-1" },
over: { id: "swim:parent:none:todo" },
});
});
expect(mockOnMoveIssue).not.toHaveBeenCalled();
});
it("emits parent_issue_id when dragging from orphan into a parent lane", () => {
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView issues={mockIssues} onMoveIssue={mockOnMoveIssue} />,
);
const target = "swim:parent:parent-1:todo";
act(() => {
lastOnDragOver({
active: { id: "orphan-1" },
over: { id: target },
});
});
act(() => {
lastOnDragEnd({
active: { id: "orphan-1" },
over: { id: target },
});
});
expect(mockOnMoveIssue).toHaveBeenCalledWith(
"orphan-1",
expect.objectContaining({
parent_issue_id: "parent-1",
status: "todo",
}),
);
});
it("renders count for hidden statuses from in-memory statusTotals", () => {
renderWithI18n(
<SwimLaneView
issues={mockIssues}
visibleStatuses={["todo", "in_progress", "in_review", "done"]}
hiddenStatuses={["backlog", "blocked"]}
onMoveIssue={vi.fn()}
/>,
);
const panel = screen.getByText("Hidden columns").parentElement!.parentElement!;
expect(panel).toHaveTextContent("Backlog");
expect(panel).toHaveTextContent("Blocked");
expect(panel).toHaveTextContent("1");
expect(panel).toHaveTextContent("0");
});
it("hidden-column totals come from unfilteredIssues when provided", () => {
const unfiltered: Issue[] = [
...mockIssues,
{
...mockIssues[2]!,
id: "blocked-1",
identifier: "PROJ-99",
title: "Blocked Issue",
status: "blocked",
position: 500,
},
];
renderWithI18n(
<SwimLaneView
issues={mockIssues}
unfilteredIssues={unfiltered}
visibleStatuses={["todo", "in_progress", "in_review", "done"]}
hiddenStatuses={["backlog", "blocked"]}
onMoveIssue={vi.fn()}
/>,
);
const panel = screen.getByText("Hidden columns").parentElement!.parentElement!;
expect(panel).toHaveTextContent("Backlog");
expect(panel).toHaveTextContent("Blocked");
const counts = [...panel.querySelectorAll("span")].map((el) => el.textContent);
expect(counts.filter((c) => c === "1").length).toBeGreaterThanOrEqual(2);
});
const multiParentIssues: Issue[] = [
{
id: "parent-1",
workspace_id: "ws-1",
number: 1,
identifier: "PROJ-1",
title: "Parent A",
description: null,
status: "todo",
priority: "high",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 100,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
id: "parent-2",
workspace_id: "ws-1",
number: 2,
identifier: "PROJ-10",
title: "Parent B",
description: null,
status: "todo",
priority: "high",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 200,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
id: "child-of-1",
workspace_id: "ws-1",
number: 3,
identifier: "PROJ-2",
title: "Child of A",
description: null,
status: "in_progress",
priority: "medium",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: "parent-1",
project_id: null,
position: 300,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
id: "child-of-2",
workspace_id: "ws-1",
number: 4,
identifier: "PROJ-11",
title: "Child of B",
description: null,
status: "in_progress",
priority: "medium",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: "parent-2",
project_id: null,
position: 400,
start_date: null,
due_date: null,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
it("persists lane order via setSwimlaneOrder when a lane is dragged onto another", () => {
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
);
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-2" },
});
});
expect(mockSetSwimlaneOrder).toHaveBeenCalledWith(["parent-2", "parent-1"]);
});
it("appends newly-visible parents to the persisted order on first reorder", () => {
mockViewState.swimlaneOrder = ["parent-1"];
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
);
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-2" },
});
});
expect(mockSetSwimlaneOrder).toHaveBeenCalledWith(["parent-2", "parent-1"]);
});
it("preserves persisted entries that aren't currently visible during a reorder", () => {
mockViewState.swimlaneOrder = ["filtered-a", "parent-1", "filtered-b", "parent-2"];
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
);
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-2" },
});
});
expect(mockSetSwimlaneOrder).toHaveBeenCalledWith([
"filtered-a",
"parent-2",
"filtered-b",
"parent-1",
]);
});
it("does not call setSwimlaneOrder when a lane is dropped onto itself", () => {
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
);
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-1" },
});
});
expect(mockSetSwimlaneOrder).not.toHaveBeenCalled();
});
it("does not call onMoveIssue when a lane drag ends (no card mutation)", () => {
const mockOnMoveIssue = vi.fn();
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={mockOnMoveIssue} />,
);
act(() => {
lastOnDragEnd({
active: { id: "lane:parent-1" },
over: { id: "lane:parent-2" },
});
});
expect(mockOnMoveIssue).not.toHaveBeenCalled();
});
it("renders parent lanes in stored swimlaneOrder when set", () => {
mockViewState.swimlaneOrder = ["parent-2", "parent-1"];
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
);
const parentA = screen.getByText("Parent A");
const parentB = screen.getByText("Parent B");
// DOM order: "Parent B" must precede "Parent A".
// compareDocumentPosition: bitmask, DOCUMENT_POSITION_FOLLOWING = 4
expect(parentB.compareDocumentPosition(parentA) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("keeps 'No parent' lane pinned at top regardless of stored order", () => {
mockViewState.swimlaneOrder = ["parent-2", "parent-1"];
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
);
const noParent = screen.getByText("No parent");
const parentB = screen.getByText("Parent B");
expect(noParent.compareDocumentPosition(parentB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
// ------------------------------------------------------------------
// Persisted collapsed-lane state
// ------------------------------------------------------------------
it("collapses a parent lane when its id is in stored collapsedSwimlanes", () => {
mockViewState.collapsedSwimlanes = ["parent-1"];
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
);
// The lane HEADER for Parent A is still visible…
expect(screen.getByText("Parent A")).toBeInTheDocument();
// …but its child card is not.
expect(screen.queryByText("Child of A")).not.toBeInTheDocument();
// Parent B (not collapsed) still shows its child.
expect(screen.getByText("Child of B")).toBeInTheDocument();
});
it("collapses the 'No parent' lane when 'none' is in stored collapsedSwimlanes", () => {
mockViewState.collapsedSwimlanes = ["none"];
renderWithI18n(
<SwimLaneView issues={mockIssues} onMoveIssue={vi.fn()} />,
);
expect(screen.getByText("No parent")).toBeInTheDocument();
expect(screen.queryByText("Orphan Issue 1")).not.toBeInTheDocument();
});
it("calls toggleSwimlaneCollapsed with the raw parent id when a lane header is clicked", () => {
renderWithI18n(
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
);
const parentAHeader = screen.getByText("Parent A").closest("button");
expect(parentAHeader).not.toBeNull();
fireEvent.click(parentAHeader!);
expect(mockToggleSwimlaneCollapsed).toHaveBeenCalledWith("parent-1");
});
it("calls toggleSwimlaneCollapsed with 'none' when the No-parent lane header is clicked", () => {
renderWithI18n(
<SwimLaneView issues={mockIssues} onMoveIssue={vi.fn()} />,
);
// "No parent" appears twice (lane title + orphan-1's card description);
// find the one inside a button.
const matches = screen.getAllByText("No parent");
const noParentHeader = matches.map((m) => m.closest("button")).find(Boolean);
expect(noParentHeader).toBeDefined();
fireEvent.click(noParentHeader!);
expect(mockToggleSwimlaneCollapsed).toHaveBeenCalledWith("none");
});
});
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
import {
pointerWithin,
closestCenter,
type CollisionDetection,
} from "@dnd-kit/core";
import type { Issue, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
import type { IssueGrouping } from "@multica/core/issues/stores/view-store";
import type { BoardColumnGroup } from "../components/board-column";
export type DragMoveUpdates = Pick<
UpdateIssueRequest,
"status" | "assignee_type" | "assignee_id" | "position"
>;
const UNASSIGNED_GROUP_ID = "assignee:unassigned";
export function makeKanbanCollision(groupIds: Set<string>): CollisionDetection {
return (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) {
const items = pointer.filter((c) => !groupIds.has(c.id as string));
if (items.length > 0) return items;
return pointer;
}
return closestCenter(args);
};
}
export function statusGroupId(status: IssueStatus): string {
return `status:${status}`;
}
export function assigneeGroupId(
type: IssueAssigneeType | null,
id: string | null,
): string {
return type && id ? `assignee:${type}:${id}` : UNASSIGNED_GROUP_ID;
}
export function getIssueGroupId(issue: Issue, grouping: IssueGrouping): string {
if (grouping === "status") return statusGroupId(issue.status);
return assigneeGroupId(issue.assignee_type, issue.assignee_id);
}
export function buildColumns(
issues: Issue[],
groups: BoardColumnGroup[],
grouping: IssueGrouping,
): Record<string, string[]> {
const cols: Record<string, string[]> = {};
for (const group of groups) cols[group.id] = [];
for (const issue of issues) {
const gid = getIssueGroupId(issue, grouping);
if (cols[gid]) cols[gid].push(issue.id);
}
return cols;
}
export function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
const idx = ids.indexOf(activeId);
if (idx === -1) return 0;
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
if (idx === 0) return getPos(ids[1]!) - 1;
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
}
export function findColumn(
columns: Record<string, string[]>,
id: string,
columnIds: Set<string>,
): string | null {
if (columnIds.has(id)) return id;
for (const [columnId, ids] of Object.entries(columns)) {
if (ids.includes(id)) return columnId;
}
return null;
}
export function issueMatchesGroup(issue: Issue, group: BoardColumnGroup): boolean {
if (group.status) return issue.status === group.status;
return (
(issue.assignee_type ?? null) === (group.assigneeType ?? null) &&
(issue.assignee_id ?? null) === (group.assigneeId ?? null)
);
}
export function getMoveUpdates(
group: BoardColumnGroup,
position: number,
): DragMoveUpdates {
if (group.status) return { status: group.status, position };
return {
assignee_type: group.assigneeType ?? null,
assignee_id: group.assigneeId ?? null,
position,
};
}
+2 -11
View File
@@ -242,10 +242,7 @@
"list_loading": "Loading…",
"list_empty_default": "No skills available.",
"list_no_match": "No skills match.",
"search_placeholder": "Search skills…",
"local_label": "Allow locally installed skills",
"local_hint_off": "Off — for Claude Code and Codex, the agent ignores the host machine's user-global skill directory (~/.claude/skills/, ~/.codex/skills/). Recommended for shared agents — a broken local skill on one operator's machine cannot crash this agent. Other runtimes ignore this setting.",
"local_hint_on": "On (default) — for Claude Code and Codex, the host machine's user-global skill directory is merged alongside workspace skills. Other runtimes ignore this setting."
"search_placeholder": "Search skills…"
},
"avatar": {
"change_aria": "Change avatar",
@@ -298,14 +295,8 @@
"save_failed_toast": "Failed to save custom arguments"
},
"skills": {
"intro": "Workspace skills assigned to this agent. For Claude Code and Codex, the host machine's user-global skill directory is merged by default — switch the toggle below off to isolate this agent from those host-machine skills. Other runtimes ignore this setting.",
"intro": "Workspace skills assigned to this agent. Local runtime skills are always available automatically.",
"add_action": "Add skill",
"import_hint": "Importing creates a workspace copy that your team can edit and reuse.",
"local_label": "Allow locally installed skills",
"local_hint_off": "Off — for Claude Code and Codex, the agent ignores the host machine's user-global skill directory (~/.claude/skills/, ~/.codex/skills/). Recommended for shared agents — a broken local skill on one operator's machine cannot crash this agent. Other runtimes ignore this setting.",
"local_hint_on": "On (default) — for Claude Code and Codex, the host machine's user-global skill directory is merged alongside workspace skills. Other runtimes ignore this setting.",
"local_saved_toast": "Skill merge setting saved",
"local_save_failed_toast": "Failed to save skill merge setting",
"empty_title": "No skills assigned",
"empty_hint": "Add workspace skills to share team knowledge with this agent.",
"remove_failed_toast": "Failed to remove skill",
+14 -3
View File
@@ -48,7 +48,9 @@
"squads_group": "Squads",
"issue_count_one": "{{count}} issue",
"issue_count_other": "{{count}} issues",
"reset": "Reset all filters"
"reset": "Reset all filters",
"active_count_one": "{{count}} filter",
"active_count_other": "{{count}} filters"
},
"display": {
"tooltip": "Display settings",
@@ -78,10 +80,12 @@
"tooltip_board": "Board view",
"tooltip_list": "List view",
"tooltip_gantt": "Gantt view",
"tooltip_swimlane": "Swimlane view",
"section": "View",
"board": "Board",
"list": "List",
"gantt": "Gantt"
"gantt": "Gantt",
"swimlane": "Swimlane"
},
"gantt": {
"header_issue": "Issue",
@@ -120,13 +124,20 @@
"empty_status": "No issues",
"add_issue_tooltip": "Add issue"
},
"swimlane": {
"no_parent": "No parent",
"other_parents": "Other parents",
"open_parent": "Open parent issue",
"toggle_collapse": "Toggle lane"
},
"board": {
"hidden_columns_label": "Hidden columns",
"hide_column": "Hide column",
"show_column": "Show column",
"add_issue_tooltip": "Add issue",
"empty_column": "No issues",
"empty_grouping": "No matching issues"
"empty_grouping": "No matching issues",
"ordered_by": "Board ordered by {{field}}"
},
"detail": {
"not_found": "This issue does not exist or has been deleted in this workspace.",
+2
View File
@@ -32,9 +32,11 @@
"card_properties": "Card properties",
"view_board": "Board view",
"view_list": "List view",
"view_swimlane": "Swimlane view",
"view_label": "View",
"view_board_short": "Board",
"view_list_short": "List",
"view_swimlane_short": "Swimlane",
"sort_manual": "Manual"
},
"errors": {
+39 -5
View File
@@ -111,11 +111,45 @@
"visibility_toast_updated": "Visibility set to {{visibility}}",
"visibility_toast_failed": "Failed to update visibility",
"delete_dialog": {
"title": "Delete Runtime",
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete",
"deleting": "Deleting..."
"light": {
"title": "Delete Runtime?",
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete runtime",
"submitting": "Deleting..."
},
"cascade": {
"title_one": "Archive {{count}} agent and delete this Runtime?",
"title_other": "Archive {{count}} agents and delete this Runtime?",
"description": "Delete \"{{name}}\". The agents below will be archived and removed from active workflows; their queued or running tasks will be cancelled, and then the Runtime will be deleted.",
"warning": "This is destructive. Archived agents will be removed from active workflows and their queued or running tasks will be cancelled.",
"notice_runtime_has_active_agents": "Active agents were added since you opened this dialog. Review the new plan and confirm.",
"notice_runtime_delete_plan_changed": "The active agent set changed while this dialog was open. Review the new plan and confirm.",
"checkbox_one": "I understand this {{count}} agent will be archived and any queued or running tasks will be cancelled.",
"checkbox_other": "I understand these {{count}} agents will be archived and any queued or running tasks will be cancelled.",
"cancel": "Cancel",
"confirm_one": "Archive {{count}} agent and delete runtime",
"confirm_other": "Archive {{count}} agents and delete runtime",
"submitting": "Archiving and deleting...",
"self_healing_blocked_toast": "This runtime is managed by a running local daemon and will re-register itself.",
"delete_failed_toast": "Failed to delete runtime",
"table": {
"header_agent": "Agent",
"header_owner": "Owner",
"header_status": "Status",
"header_visibility": "Visibility",
"header_model": "Model",
"owner_self": "You",
"owner_unassigned": "—",
"model_unset": "—",
"presence_unknown": "—",
"workload_idle": "Idle",
"workload_working": "Working",
"workload_queued": "Queued",
"visibility_workspace": "Workspace",
"visibility_private": "Private"
}
}
},
"toast_deleted": "Runtime deleted",
"toast_delete_failed": "Failed to delete runtime",
+2 -11
View File
@@ -237,10 +237,7 @@
"list_loading": "加载中…",
"list_empty_default": "暂无 skill。",
"list_no_match": "没有匹配的 skill。",
"search_placeholder": "搜索 skill…",
"local_label": "允许使用本机已安装的 Skill",
"local_hint_off": "关闭 —— 对 Claude Code 与 Codex,智能体会忽略本机用户级 skill 目录(~/.claude/skills/、~/.codex/skills/)。推荐用于共享智能体 —— 团队成员机器上有问题的本机 skill 不会拖崩这个智能体。其它运行时会忽略此设置。",
"local_hint_on": "开启(默认)—— 对 Claude Code 与 Codex,运行时会同时合并本机用户级 skill 目录。其它运行时会忽略此设置。"
"search_placeholder": "搜索 skill…"
},
"avatar": {
"change_aria": "更换头像",
@@ -292,14 +289,8 @@
"save_failed_toast": "保存自定义参数失败"
},
"skills": {
"intro": "分配给该智能体的工作区 skill。对 Claude Code 与 Codex,默认会同时合并本机用户级 skill 目录 —— 把下方开关关闭可以让该智能体与本机用户级 skill 隔离。其它运行时会忽略此设置。",
"intro": "分配给该智能体的工作区 skill。本地运行时 skill 会自动可用。",
"add_action": "添加 skill",
"import_hint": "导入会在工作区创建一份副本,团队可以编辑和复用。",
"local_label": "允许使用本机已安装的 Skill",
"local_hint_off": "关闭 —— 对 Claude Code 与 Codex,智能体会忽略本机用户级 skill 目录(~/.claude/skills/、~/.codex/skills/)。推荐用于共享智能体 —— 团队成员机器上有问题的本机 skill 不会拖崩这个智能体。其它运行时会忽略此设置。",
"local_hint_on": "开启(默认)—— 对 Claude Code 与 Codex,运行时会同时合并本机用户级 skill 目录。其它运行时会忽略此设置。",
"local_saved_toast": "已保存 Skill 合并设置",
"local_save_failed_toast": "保存 Skill 合并设置失败",
"empty_title": "尚未分配 skill",
"empty_hint": "添加工作区 skill,把团队知识共享给这个智能体。",
"remove_failed_toast": "移除 skill 失败",
+13 -3
View File
@@ -47,7 +47,8 @@
"agents_group": "智能体",
"squads_group": "小队",
"issue_count_other": "{{count}} 个 issue",
"reset": "重置全部筛选"
"reset": "重置全部筛选",
"active_count_other": "{{count}} 个筛选"
},
"display": {
"tooltip": "显示设置",
@@ -77,10 +78,12 @@
"tooltip_board": "看板视图",
"tooltip_list": "列表视图",
"tooltip_gantt": "甘特图",
"tooltip_swimlane": "泳道视图",
"section": "视图",
"board": "看板",
"list": "列表",
"gantt": "甘特图"
"gantt": "甘特图",
"swimlane": "泳道"
},
"gantt": {
"header_issue": "Issue",
@@ -119,13 +122,20 @@
"empty_status": "无 issue",
"add_issue_tooltip": "新建 issue"
},
"swimlane": {
"no_parent": "无父级",
"other_parents": "其他父级",
"open_parent": "打开父级 issue",
"toggle_collapse": "切换泳道"
},
"board": {
"hidden_columns_label": "隐藏的列",
"hide_column": "隐藏列",
"show_column": "显示列",
"add_issue_tooltip": "新建 issue",
"empty_column": "无 issue",
"empty_grouping": "没有匹配的 issue"
"empty_grouping": "没有匹配的 issue",
"ordered_by": "按{{field}}排序"
},
"detail": {
"not_found": "这个 issue 不存在或已在该工作区被删除。",
@@ -31,9 +31,11 @@
"card_properties": "卡片属性",
"view_board": "看板视图",
"view_list": "列表视图",
"view_swimlane": "泳道视图",
"view_label": "视图",
"view_board_short": "看板",
"view_list_short": "列表",
"view_swimlane_short": "泳道",
"sort_manual": "手动"
},
"errors": {
+36 -5
View File
@@ -106,11 +106,42 @@
"visibility_toast_updated": "可见性已设为「{{visibility}}」",
"visibility_toast_failed": "更新可见性失败",
"delete_dialog": {
"title": "删除运行时",
"description": "确定要删除\"{{name}}\"吗?此操作不可撤销。",
"cancel": "取消",
"confirm": "删除",
"deleting": "删除中..."
"light": {
"title": "删除运行时?",
"description": "确定要删除「{{name}}」吗?此操作无法撤销。",
"cancel": "取消",
"confirm": "删除运行时",
"submitting": "删除中..."
},
"cascade": {
"title_other": "归档 {{count}} 个智能体并删除该运行时?",
"description": "删除「{{name}}」。下列智能体将被归档并从可用工作流中移除,其排队中或运行中的 task 会被取消,随后运行时本身会被删除。",
"warning": "此操作具有破坏性。归档的智能体将从可用工作流中移除,其排队中或运行中的 task 会被取消。",
"notice_runtime_has_active_agents": "自打开此弹窗后,又有新的活动智能体加入。请确认新方案后继续。",
"notice_runtime_delete_plan_changed": "在弹窗打开期间,活动智能体集合发生了变化。请确认新方案后继续。",
"checkbox_other": "我已了解:这 {{count}} 个智能体将被归档,其排队中或运行中的 task 会被取消。",
"cancel": "取消",
"confirm_other": "归档 {{count}} 个智能体并删除运行时",
"submitting": "正在归档并删除...",
"self_healing_blocked_toast": "该运行时由正在运行的本地守护进程托管,删除后会自动重新注册。",
"delete_failed_toast": "删除运行时失败",
"table": {
"header_agent": "智能体",
"header_owner": "所有者",
"header_status": "状态",
"header_visibility": "可见性",
"header_model": "模型",
"owner_self": "你",
"owner_unassigned": "—",
"model_unset": "—",
"presence_unknown": "—",
"workload_idle": "空闲",
"workload_working": "运行中",
"workload_queued": "排队中",
"visibility_workspace": "工作区",
"visibility_private": "私有"
}
}
},
"toast_deleted": "已删除运行时",
"toast_delete_failed": "删除运行时失败",
@@ -2,115 +2,16 @@
import { useMemo } from "react";
import { useStore } from "zustand";
import {
ArrowDown,
ArrowUp,
Check,
ChevronDown,
CircleDot,
Columns3,
Filter,
List,
SignalHigh,
SlidersHorizontal,
} from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@multica/ui/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Switch } from "@multica/ui/components/ui/switch";
import {
ALL_STATUSES,
STATUS_CONFIG,
PRIORITY_ORDER,
PRIORITY_CONFIG,
} from "@multica/core/issues/config";
import { StatusIcon, PriorityIcon } from "../../issues/components";
import {
SORT_OPTIONS,
GROUPING_OPTIONS,
CARD_PROPERTY_OPTIONS,
} from "@multica/core/issues/stores/view-store";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import type { Issue } from "@multica/core/types";
import { myIssuesViewStore, type MyIssuesScope } from "@multica/core/issues/stores/my-issues-view-store";
import { useT } from "../../i18n";
import { WorkspaceAgentWorkingChip } from "../../issues/components/workspace-agent-working-chip";
// ---------------------------------------------------------------------------
// HoverCheck
// ---------------------------------------------------------------------------
const FILTER_ITEM_CLASS =
"group/fitem pr-1.5! [&>[data-slot=dropdown-menu-checkbox-item-indicator]]:hidden";
function HoverCheck({ checked }: { checked: boolean }) {
return (
<div
className="border-input data-[selected=true]:border-primary data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground pointer-events-none size-4 shrink-0 rounded-[4px] border transition-all select-none *:[svg]:opacity-0 data-[selected=true]:*:[svg]:opacity-100 opacity-0 group-hover/fitem:opacity-100 group-focus/fitem:opacity-100 data-[selected=true]:opacity-100"
data-selected={checked}
>
<Check className="size-3.5 text-current" />
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getActiveFilterCount(state: {
statusFilters: string[];
priorityFilters: string[];
}) {
let count = 0;
if (state.statusFilters.length > 0) count++;
if (state.priorityFilters.length > 0) count++;
return count;
}
function useIssueCounts(allIssues: Issue[]) {
return useMemo(() => {
const status = new Map<string, number>();
const priority = new Map<string, number>();
for (const issue of allIssues) {
status.set(issue.status, (status.get(issue.status) ?? 0) + 1);
priority.set(issue.priority, (priority.get(issue.priority) ?? 0) + 1);
}
return { status, priority };
}, [allIssues]);
}
// ---------------------------------------------------------------------------
// Scope config
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// MyIssuesHeader
// ---------------------------------------------------------------------------
import { IssueDisplayControls } from "../../issues/components/issues-header";
export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
const { t } = useT("my-issues");
// Pulls the chip-wide "Viewing only working agents" label from the
// shared issues namespace so the copy stays identical with the global
// /issues page header — single source of truth for this filter cue.
const { t: tIssues } = useT("issues");
const SCOPES: { value: MyIssuesScope; label: string; description: string }[] = [
{ value: "all", label: t(($) => $.header.scope.all_label), description: t(($) => $.header.scope.all_description) },
@@ -118,41 +19,16 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
{ value: "created", label: t(($) => $.header.scope.created_label), description: t(($) => $.header.scope.created_description) },
{ value: "agents", label: t(($) => $.header.scope.agents_label), description: t(($) => $.header.scope.agents_description) },
];
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy);
const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection);
const grouping = useStore(myIssuesViewStore, (s) => s.grouping);
const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
const scope = useStore(myIssuesViewStore, (s) => s.scope);
const agentRunningFilter = useStore(myIssuesViewStore, (s) => s.agentRunningFilter);
const act = myIssuesViewStore.getState();
// Limit the chip to issues actually visible on the My Issues page —
// without this scoping, the chip would report workspace-wide running
// agents (e.g. 3) while the my-scope list only contains one of them,
// and the post-toggle list count would never match the chip number.
const scopedIssueIds = useMemo(
() => new Set(allIssues.map((i) => i.id)),
[allIssues],
);
const counts = useIssueCounts(allIssues);
const hasActiveFilters =
getActiveFilterCount({ statusFilters, priorityFilters }) > 0;
const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? t(($) => $.header.sort_manual);
const GROUPING_LABEL_KEY: Record<typeof GROUPING_OPTIONS[number]["value"], "group_status" | "group_assignee"> = {
status: "group_status",
assignee: "group_assignee",
};
const groupingLabel = t(($) => $.header[GROUPING_LABEL_KEY[grouping]]);
return (
<div className="flex h-12 shrink-0 items-center justify-between px-4">
{/* Left: scope buttons */}
<div className="flex items-center gap-1">
{SCOPES.map((s) => (
<Tooltip key={s.value}>
@@ -177,7 +53,6 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
))}
</div>
{/* Right: agent working chip + filter + display + view toggle */}
<div className="flex items-center gap-1">
{agentRunningFilter && (
<span className="mr-1 text-xs text-muted-foreground">
@@ -189,268 +64,7 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
onToggle={act.toggleAgentRunningFilter}
scopedIssueIds={scopedIssueIds}
/>
{/* Filter */}
<DropdownMenu>
<Tooltip>
<DropdownMenuTrigger
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="relative text-muted-foreground">
<Filter className="size-4" />
{hasActiveFilters && (
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
)}
</Button>
}
/>
}
/>
<TooltipContent side="bottom">{t(($) => $.header.filter_button)}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
{/* Status */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<CircleDot className="size-3.5" />
<span className="flex-1">{t(($) => $.header.filter_status)}</span>
{statusFilters.length > 0 && (
<span className="text-xs text-primary font-medium">
{statusFilters.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-48">
{ALL_STATUSES.map((s) => {
const checked = statusFilters.includes(s);
const count = counts.status.get(s) ?? 0;
return (
<DropdownMenuCheckboxItem
key={s}
checked={checked}
onCheckedChange={() => act.toggleStatusFilter(s)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{t(($) => $.header.issue_count, { count })}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Priority */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SignalHigh className="size-3.5" />
<span className="flex-1">{t(($) => $.header.filter_priority)}</span>
{priorityFilters.length > 0 && (
<span className="text-xs text-primary font-medium">
{priorityFilters.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-44">
{PRIORITY_ORDER.map((p) => {
const checked = priorityFilters.includes(p);
const count = counts.priority.get(p) ?? 0;
return (
<DropdownMenuCheckboxItem
key={p}
checked={checked}
onCheckedChange={() => act.togglePriorityFilter(p)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{t(($) => $.header.issue_count, { count })}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Reset */}
{hasActiveFilters && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={act.clearFilters}>
{t(($) => $.header.reset_filters)}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Display settings */}
<Popover>
<Tooltip>
<PopoverTrigger
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
<SlidersHorizontal className="size-4" />
</Button>
}
/>
}
/>
<TooltipContent side="bottom">{t(($) => $.header.display_settings)}</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-64 p-0">
{viewMode === "board" && (
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.header.grouping)}
</span>
<div className="mt-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs"
>
{groupingLabel}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{GROUPING_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setGrouping(opt.value)}
>
{t(($) => $.header[GROUPING_LABEL_KEY[opt.value]])}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.header.ordering)}
</span>
<div className="mt-2 flex items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="flex-1 justify-between text-xs"
>
{sortLabel}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{SORT_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setSortBy(opt.value)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
act.setSortDirection(
sortDirection === "asc" ? "desc" : "asc",
)
}
title={sortDirection === "asc" ? t(($) => $.header.ascending) : t(($) => $.header.descending)}
>
{sortDirection === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)}
</Button>
</div>
</div>
<div className="px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.header.card_properties)}
</span>
<div className="mt-2 space-y-2">
{CARD_PROPERTY_OPTIONS.map((opt) => (
<label
key={opt.key}
className="flex cursor-pointer items-center justify-between"
>
<span className="text-sm">{opt.label}</span>
<Switch
size="sm"
checked={cardProperties[opt.key]}
onCheckedChange={() => act.toggleCardProperty(opt.key)}
/>
</label>
))}
</div>
</div>
</PopoverContent>
</Popover>
{/* View toggle */}
<DropdownMenu>
<Tooltip>
<DropdownMenuTrigger
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
{viewMode === "board" ? (
<Columns3 className="size-4" />
) : (
<List className="size-4" />
)}
</Button>
}
/>
}
/>
<TooltipContent side="bottom">
{viewMode === "board" ? t(($) => $.header.view_board) : t(($) => $.header.view_list)}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>{t(($) => $.header.view_label)}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
<Columns3 />
{t(($) => $.header.view_board_short)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
<List />
{t(($) => $.header.view_list_short)}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<IssueDisplayControls scopedIssues={allIssues} />
</div>
</div>
);
@@ -16,6 +16,7 @@ import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-contex
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { BoardView } from "../../issues/components/board-view";
import { ListView } from "../../issues/components/list-view";
import { SwimLaneView } from "../../issues/components/swimlane-view";
import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar";
import { useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -37,9 +38,19 @@ export function MyIssuesPage() {
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
const scope = useStore(myIssuesViewStore, (s) => s.scope);
const grouping = useStore(myIssuesViewStore, (s) => s.grouping);
const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy);
const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection);
const agentRunningFilter = useStore(myIssuesViewStore, (s) => s.agentRunningFilter);
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
const sort = useMemo(
() => ({
sort_by: sortBy,
sort_direction: sortBy !== "position" ? sortDirection : undefined,
} as const),
[sortBy, sortDirection],
);
// See issues-page.tsx for the rationale — derive a workspace-wide set
// of issue ids with at least one running task, drive the "agents
// working" quick-filter from it.
@@ -97,9 +108,10 @@ export function MyIssuesPage() {
scope,
assigneeGroupFilter,
user?.id,
sort,
);
const statusIssuesQuery = useQuery({
...myIssueListOptions(wsId, scope, filter, user?.id),
...myIssueListOptions(wsId, scope, filter, user?.id, sort),
enabled: !usesAssigneeBoard,
});
const assigneeGroupsQuery = useQuery({
@@ -135,6 +147,24 @@ export function MyIssuesPage() {
[myIssues, statusFilters, priorityFilters, agentRunningFilter, runningIssueIds],
);
// Status-unfiltered companion for Swimlane.
const swimlaneIssues = useMemo(
() =>
filterIssues(myIssues, {
statusFilters: [],
priorityFilters,
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
labelFilters: [],
agentRunningFilter,
runningIssueIds,
}),
[myIssues, priorityFilters, agentRunningFilter, runningIssueIds],
);
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
const visibleStatuses = useMemo(() => {
@@ -149,7 +179,7 @@ export function MyIssuesPage() {
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position" | "parent_issue_id">, onSettled?: () => void) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{
@@ -159,6 +189,7 @@ export function MyIssuesPage() {
? err.message
: t(($) => $.errors.move_failed),
),
onSettled: () => onSettled?.(),
},
);
},
@@ -217,11 +248,9 @@ export function MyIssuesPage() {
<span className="text-sm font-medium">{t(($) => $.page.breadcrumb)}</span>
</PageHeader>
{/* Header: scope tabs (left) + controls (right) */}
<MyIssuesHeader allIssues={myIssues} />
{/* Content: scrollable */}
<ViewStoreProvider store={myIssuesViewStore}>
{/* Header: scope tabs (left) + controls (right) */}
<MyIssuesHeader allIssues={myIssues} />
{myIssues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
@@ -242,6 +271,19 @@ export function MyIssuesPage() {
childProgressMap={childProgressMap}
myIssuesScope={scope}
myIssuesFilter={filter}
sort={sort}
/>
) : viewMode === "swimlane" ? (
<SwimLaneView
issues={issues}
unfilteredIssues={swimlaneIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
myIssuesScope={scope}
myIssuesFilter={filter}
sort={sort}
/>
) : (
<ListView
@@ -250,6 +292,8 @@ export function MyIssuesPage() {
childProgressMap={childProgressMap}
myIssuesScope={scope}
myIssuesFilter={filter}
sort={sort}
onMoveIssue={handleMoveIssue}
/>
)}
</div>
@@ -18,6 +18,7 @@ import {
projectGanttIssuesOptions,
childIssueProgressOptions,
type AssigneeGroupedIssuesFilter,
type IssueSortParam,
type MyIssuesFilter,
} from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
@@ -41,6 +42,7 @@ import { IssuesHeader } from "../../issues/components/issues-header";
import { BoardView } from "../../issues/components/board-view";
import { ListView } from "../../issues/components/list-view";
import { GanttView } from "../../issues/components/gantt-view";
import { SwimLaneView } from "../../issues/components/swimlane-view";
import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
@@ -115,6 +117,7 @@ function ProjectIssuesContent({
assigneeGroupFilter,
scope,
filter,
sort,
ganttIssues,
}: {
projectId: string;
@@ -124,6 +127,7 @@ function ProjectIssuesContent({
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
scope: string;
filter: MyIssuesFilter;
sort?: IssueSortParam;
ganttIssues: Issue[];
}) {
const { t } = useT("projects");
@@ -141,6 +145,12 @@ function ProjectIssuesContent({
[projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters],
);
// Status-unfiltered companion for Swimlane.
const swimlaneIssues = useMemo(
() => filterIssues(projectIssues, { statusFilters: [], priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters }),
[projectIssues, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters],
);
// Gantt rides its own dedicated query (scheduled-only) so it doesn't have
// to wait for every status bucket to paginate in. View-store filters still
// apply so toggling priority / assignee / label hides the same bars.
@@ -164,7 +174,7 @@ function ProjectIssuesContent({
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position" | "parent_issue_id">, onSettled?: () => void) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{
@@ -174,18 +184,19 @@ function ProjectIssuesContent({
? err.message
: t(($) => $.detail.toast_move_issue_failed),
),
onSettled: () => onSettled?.(),
},
);
},
[updateIssueMutation, t],
);
// Gantt has its own data source (scheduled-only) and its own empty axis —
// we never short-circuit it here, otherwise an unscheduled-but-non-empty
// project would surface a misleading "no issues" CTA. For Board/List the
// bucketed cache really is the ground truth, so an empty result means an
// empty project.
if (viewMode !== "gantt" && projectIssues.length === 0) {
// Gantt and Swimlane have their own data sources and empty states —
// we never short-circuit them here, otherwise an unscheduled/unparented
// but non-empty project would surface a misleading "no issues" CTA.
// For Board/List the bucketed cache really is the ground truth,
// so an empty result means an empty project.
if (viewMode !== "gantt" && viewMode !== "swimlane" && projectIssues.length === 0) {
return (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
@@ -220,6 +231,7 @@ function ProjectIssuesContent({
childProgressMap={childProgressMap}
myIssuesScope={scope}
myIssuesFilter={filter}
sort={sort}
projectId={projectId}
/>
)}
@@ -230,10 +242,26 @@ function ProjectIssuesContent({
childProgressMap={childProgressMap}
myIssuesScope={scope}
myIssuesFilter={filter}
sort={sort}
projectId={projectId}
onMoveIssue={handleMoveIssue}
/>
)}
{viewMode === "gantt" && <GanttView issues={filteredGanttIssues} />}
{viewMode === "swimlane" && (
<SwimLaneView
issues={issues}
unfilteredIssues={swimlaneIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
childProgressMap={childProgressMap}
myIssuesScope={scope}
myIssuesFilter={filter}
sort={sort}
projectId={projectId}
/>
)}
</div>
);
}
@@ -250,6 +278,8 @@ function ProjectIssuesSurface({
const wsId = useWorkspaceId();
const viewMode = useViewStore((s) => s.viewMode);
const grouping = useViewStore((s) => s.grouping);
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const statusFilters = useViewStore((s) => s.statusFilters);
const priorityFilters = useViewStore((s) => s.priorityFilters);
const assigneeFilters = useViewStore((s) => s.assigneeFilters);
@@ -258,6 +288,15 @@ function ProjectIssuesSurface({
const labelFilters = useViewStore((s) => s.labelFilters);
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
const usesGantt = viewMode === "gantt";
const sort = useMemo(
() => ({
sort_by: sortBy,
sort_direction: sortBy !== "position" ? sortDirection : undefined,
} as const),
[sortBy, sortDirection],
);
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(
() => ({
...filter,
@@ -274,6 +313,8 @@ function ProjectIssuesSurface({
wsId,
scope,
assigneeGroupFilter,
undefined,
sort,
);
// Each view owns exactly one data source. Board/List ride the bucketed
// `myIssueListOptions` cache; the assignee-grouped board uses the grouped
@@ -281,7 +322,7 @@ function ProjectIssuesSurface({
// the current view so switching to Gantt doesn't re-trigger the full
// per-status fetch in the background.
const statusIssuesQuery = useQuery({
...myIssueListOptions(wsId, scope, filter),
...myIssueListOptions(wsId, scope, filter, undefined, sort),
enabled: !usesAssigneeBoard && !usesGantt,
});
const assigneeGroupsQuery = useQuery({
@@ -317,6 +358,7 @@ function ProjectIssuesSurface({
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
scope={scope}
filter={filter}
sort={sort}
ganttIssues={ganttIssues}
/>
<BatchActionToolbar />
@@ -0,0 +1,321 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Agent, AgentRuntime } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enRuntimes from "../../locales/en/runtimes.json";
import enAgents from "../../locales/en/agents.json";
const TEST_RESOURCES = {
en: { common: enCommon, runtimes: enRuntimes, agents: enAgents },
};
// ApiError mirrors the production export. The dialog's parseActiveAgentsConflict
// uses an `instanceof` check, so the class identity must match the one the
// mocked api throws. vi.hoisted is required because vi.mock is hoisted above
// imports — a top-level class declaration would not be visible to the mock
// factory at hoist time.
const { ApiError, apiDeleteRuntime, apiArchiveAgentsAndDeleteRuntime } = vi.hoisted(() => {
class ApiError extends Error {
status: number;
body: unknown;
constructor(message: string, status: number, body: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
return {
ApiError,
apiDeleteRuntime: vi.fn(),
apiArchiveAgentsAndDeleteRuntime: vi.fn(),
};
});
vi.mock("@multica/core/api", () => ({
api: {
deleteRuntime: (...args: unknown[]) => apiDeleteRuntime(...args),
archiveAgentsAndDeleteRuntime: (...args: unknown[]) =>
apiArchiveAgentsAndDeleteRuntime(...args),
listAgents: vi.fn(),
listMembers: vi.fn(),
},
ApiError,
}));
// The mutations file imports api lazily via the mock above; the mocked
// hooks below thread directly to the api stubs so the dialog's mode
// transitions are deterministic in this test.
vi.mock("@multica/core/runtimes/mutations", () => ({
useDeleteRuntime: () => ({
isPending: false,
mutate: vi.fn(),
mutateAsync: (...args: unknown[]) => apiDeleteRuntime(...args),
}),
useArchiveAgentsAndDeleteRuntime: () => ({
isPending: false,
mutate: vi.fn(),
mutateAsync: (vars: { runtimeId: string; expectedActiveAgentIds: string[] }) =>
apiArchiveAgentsAndDeleteRuntime(vars.runtimeId, vars.expectedActiveAgentIds),
}),
}));
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
// The dialog reads agentListOptions / memberListOptions through useQuery.
// The default returns an empty list — individual tests override
// mockUseQuery to return populated agents when they want cascade-from-cache.
useQuery: vi.fn(() => ({ data: [], isLoading: false })),
};
});
vi.mock("@multica/core/agents", () => ({
// Empty presence map keeps the cell renderers honest without dragging in
// the full presence pipeline.
useWorkspacePresenceMap: () => ({ byAgent: new Map(), loading: false }),
}));
vi.mock("@multica/core/auth", () => ({
useAuthStore: (sel: (s: { user: { id: string } }) => unknown) =>
sel({ user: { id: "user-me" } }),
}));
vi.mock("../../common/actor-avatar", () => ({ ActorAvatar: () => null }));
vi.mock("../../agents/presence", () => ({
availabilityConfig: {
online: { dotClass: "", textClass: "" },
unstable: { dotClass: "", textClass: "" },
offline: { dotClass: "", textClass: "" },
},
workloadConfig: {
working: { icon: () => null, textClass: "" },
queued: { icon: () => null, textClass: "" },
idle: { icon: () => null, textClass: "" },
},
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
import { useQuery } from "@tanstack/react-query";
import { DeleteRuntimeDialog } from "./delete-runtime-dialog";
const mockedUseQuery = vi.mocked(useQuery);
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
return {
id: "rt-1",
workspace_id: "ws-1",
daemon_id: null,
name: "Cloud Runtime",
runtime_mode: "cloud",
provider: "claude",
launch_header: "",
status: "online",
device_info: "",
metadata: {},
owner_id: "user-me",
visibility: "private",
last_seen_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
function makeAgent(id: string, overrides: Partial<Agent> = {}): Agent {
return {
id,
workspace_id: "ws-1",
runtime_id: "rt-1",
name: `Agent ${id}`,
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "cloud",
runtime_config: {},
custom_args: [],
visibility: "private",
status: "idle",
max_concurrent_tasks: 1,
model: "claude-sonnet-4-5",
owner_id: "user-me",
skills: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
archived_at: null,
archived_by: null,
...overrides,
};
}
function renderDialog(opts: {
runtime?: AgentRuntime;
cachedAgents?: Agent[];
onDeleted?: () => void;
} = {}) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const onOpenChange = vi.fn();
const onDeleted = opts.onDeleted ?? vi.fn();
// Wire useQuery mock: agentListOptions returns the cached agents,
// memberListOptions returns an empty list (Owner cell renders the dash).
mockedUseQuery.mockImplementation(((queryArg: unknown) => {
const q = queryArg as { queryKey?: readonly unknown[] };
const key = q?.queryKey ?? [];
const tail = key[key.length - 1];
if (tail === "agents") {
return { data: opts.cachedAgents ?? [], isLoading: false } as unknown as ReturnType<typeof useQuery>;
}
return { data: [], isLoading: false } as unknown as ReturnType<typeof useQuery>;
}) as unknown as typeof useQuery);
const utils = render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={qc}>
<DeleteRuntimeDialog
open
onOpenChange={onOpenChange}
runtime={opts.runtime ?? makeRuntime()}
wsId="ws-1"
onDeleted={onDeleted}
/>
</QueryClientProvider>
</I18nProvider>,
);
return { ...utils, onOpenChange, onDeleted };
}
describe("DeleteRuntimeDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the light-mode prompt when no agents are bound", () => {
renderDialog({ cachedAgents: [] });
expect(screen.getByText("Delete Runtime?")).toBeInTheDocument();
expect(screen.getByText("Delete runtime")).toBeInTheDocument();
// No checkbox, no agent table in light mode.
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByText(/Archive .* and delete this Runtime/)).not.toBeInTheDocument();
});
it("opens directly in cascade mode when local cache shows bound agents, with the destructive button gated by the checkbox", async () => {
renderDialog({
cachedAgents: [
makeAgent("a-1", { name: "Alpha" }),
makeAgent("a-2", { name: "Beta" }),
],
});
expect(
screen.getByText(/Archive 2 agents and delete this Runtime/),
).toBeInTheDocument();
// Destructive confirm starts disabled until the user ticks the checkbox.
const confirm = screen.getByRole("button", {
name: /Archive 2 agents and delete runtime/,
}) as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
const checkbox = screen.getByRole("checkbox") as HTMLInputElement;
fireEvent.click(checkbox);
await waitFor(() => expect(confirm.disabled).toBe(false));
apiArchiveAgentsAndDeleteRuntime.mockResolvedValueOnce({
status: "ok",
agents_archived: 2,
tasks_cancelled: 0,
});
fireEvent.click(confirm);
await waitFor(() =>
expect(apiArchiveAgentsAndDeleteRuntime).toHaveBeenCalledWith("rt-1", [
"a-1",
"a-2",
]),
);
});
it("pivots from light to cascade mode when the strict DELETE returns runtime_has_active_agents", async () => {
const fresh = makeAgent("a-9", { name: "FreshAgent" });
apiDeleteRuntime.mockRejectedValueOnce(
new ApiError("conflict", 409, {
code: "runtime_has_active_agents",
active_agents: [fresh],
}),
);
renderDialog({ cachedAgents: [] });
// We open in light mode, hit Delete, and expect the dialog to pivot to
// cascade mode using the server-supplied agent list.
const lightConfirm = screen.getByRole("button", { name: "Delete runtime" });
fireEvent.click(lightConfirm);
await waitFor(() =>
expect(
screen.getByText(/Archive 1 agent and delete this Runtime/),
).toBeInTheDocument(),
);
expect(screen.getByText("FreshAgent")).toBeInTheDocument();
// Notice should be visible explaining the pivot.
expect(
screen.getByText(/Active agents were added since you opened this dialog/),
).toBeInTheDocument();
});
it("re-prompts when the cascade returns runtime_delete_plan_changed", async () => {
apiArchiveAgentsAndDeleteRuntime.mockRejectedValueOnce(
new ApiError("plan changed", 409, {
code: "runtime_delete_plan_changed",
active_agents: [
makeAgent("a-1", { name: "Alpha" }),
makeAgent("a-2", { name: "Beta" }),
makeAgent("a-3", { name: "Gamma" }),
],
}),
);
renderDialog({
cachedAgents: [
makeAgent("a-1", { name: "Alpha" }),
makeAgent("a-2", { name: "Beta" }),
],
});
// Tick the checkbox and confirm.
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(
screen.getByRole("button", {
name: /Archive 2 agents and delete runtime/,
}),
);
await waitFor(() =>
expect(
screen.getByText(/Archive 3 agents and delete this Runtime/),
).toBeInTheDocument(),
);
// The new third agent shows in the plan.
expect(screen.getByText("Gamma")).toBeInTheDocument();
// Checkbox is unchecked again so the user must re-confirm. The base-ui
// Checkbox renders as a <button role="checkbox"> with aria-checked, so
// we read that attribute rather than `.checked`.
expect(screen.getByRole("checkbox").getAttribute("aria-checked")).toBe(
"false",
);
// Notice copy explains why the dialog re-prompted.
expect(
screen.getByText(/active agent set changed/i),
).toBeInTheDocument();
});
});
@@ -0,0 +1,602 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AlertTriangle, Globe, Lock } from "lucide-react";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { ApiError } from "@multica/core/api";
import type { Agent, AgentRuntime, MemberWithUser } from "@multica/core/types";
import {
useDeleteRuntime,
useArchiveAgentsAndDeleteRuntime,
} from "@multica/core/runtimes/mutations";
import {
agentListOptions,
memberListOptions,
} from "@multica/core/workspace/queries";
import {
type AgentPresenceDetail,
useWorkspacePresenceMap,
} from "@multica/core/agents";
import { useAuthStore } from "@multica/core/auth";
import {
AlertDialog,
AlertDialogContent,
} from "@multica/ui/components/ui/alert-dialog";
import { Button } from "@multica/ui/components/ui/button";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
import { ActorAvatar } from "../../common/actor-avatar";
import { availabilityConfig, workloadConfig } from "../../agents/presence";
import { useT } from "../../i18n";
import { isSelfHealingRuntime } from "../utils";
// DeleteRuntimeDialog is the single confirmation surface for runtime
// deletion across the list-page kebab and the detail-page Diagnostics
// card. It runs in two modes that share the same shell — light when no
// agents are bound (matches the legacy "are you sure" prompt) and
// cascade when active agents would be archived as part of the delete.
//
// Mode is decided dynamically:
// 1. Initial: peek at the cached agent list and pick light vs cascade
// based on whether any active agent.runtime_id === runtime.id. This
// lets the dialog open in the right state without an extra request.
// 2. After the strict DELETE: if the server refuses with
// `runtime_has_active_agents` (the local data was stale), switch to
// cascade mode using the server's authoritative agent list.
// 3. After the cascade endpoint: if it refuses with
// `runtime_delete_plan_changed`, refresh the displayed list with the
// server snapshot and force the user to re-confirm the checkbox.
//
// Self-healing local runtimes (online local daemons that re-register
// themselves seconds after deletion — see isSelfHealingRuntime) are
// gated at the affordance level by the row-menu and Diagnostics card,
// so this dialog never opens for them. We re-check at confirm time as
// defence in depth in case the runtime flips while the dialog is open.
export interface DeleteRuntimeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
runtime: AgentRuntime;
wsId: string;
// Called after a successful delete. List page closes the dialog and
// toasts; detail page additionally navigates back to /runtimes.
onDeleted: () => void;
}
export function DeleteRuntimeDialog({
open,
onOpenChange,
runtime,
wsId,
onDeleted,
}: DeleteRuntimeDialogProps) {
const { t } = useT("runtimes");
// Pull cached workspace data — every consumer page already has these
// mounted, so this dialog adds zero new fetches when opened.
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { byAgent: presenceMap } = useWorkspacePresenceMap(wsId);
const user = useAuthStore((s) => s.user);
// The displayed plan starts from the cached agent list and is replaced by
// server snapshots when the backend returns a structured 409 (either on
// the initial DELETE or on a plan-changed cascade refusal). We hold it in
// state so renders are deterministic even after invalidation flickers.
const cachedActiveAgents = useMemo(
() => agents.filter((a) => a.runtime_id === runtime.id && !a.archived_at),
[agents, runtime.id],
);
const [planAgents, setPlanAgents] = useState<Agent[]>(cachedActiveAgents);
// `cascade` is true when the visible plan has at least one agent. Mode is
// a derived value, not user-controlled — the user always confirms the
// current plan; switching is the consequence of a server response, not a
// toggle.
const cascade = planAgents.length > 0;
const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Server-issued message shown above the agent table on plan-changed —
// tells the user "the list refreshed because something moved" without
// re-stating it in the title. Stored as a localized string (already
// translated) so the consumer can just render it.
const [planChangedNotice, setPlanChangedNotice] = useState<string | null>(null);
// Reset transient state every time the dialog opens. Without this a
// previous plan-changed notice or a stale checkbox could survive across
// open/close cycles and confuse the next attempt.
useEffect(() => {
if (open) {
setPlanAgents(cachedActiveAgents);
setConfirmed(false);
setSubmitting(false);
setPlanChangedNotice(null);
}
}, [open, cachedActiveAgents]);
const lightMutation = useDeleteRuntime(wsId);
const cascadeMutation = useArchiveAgentsAndDeleteRuntime(wsId);
const handleConfirm = async () => {
// Defensive re-check of the self-healing rule — the affordance is
// gated upstream, but a local daemon that came online while the
// dialog was open should still block the action.
if (isSelfHealingRuntime(runtime)) {
toast.error(
t(($) => $.detail.delete_dialog.cascade.self_healing_blocked_toast),
);
return;
}
setSubmitting(true);
setPlanChangedNotice(null);
try {
if (cascade) {
await cascadeMutation.mutateAsync({
runtimeId: runtime.id,
expectedActiveAgentIds: planAgents.map((a) => a.id),
});
onDeleted();
} else {
try {
await lightMutation.mutateAsync(runtime.id);
onDeleted();
} catch (err) {
// The strict DELETE returns a structured 409 when active
// agents were created between dialog-open and confirm. Pivot
// into cascade mode using the server's authoritative list,
// unset the checkbox, and surface a notice so the user knows
// why the dialog suddenly grew an agent table.
const conflict = parseActiveAgentsConflict(err);
if (conflict?.code === "runtime_has_active_agents") {
setPlanAgents(conflict.activeAgents);
setConfirmed(false);
setPlanChangedNotice(
t(
($) =>
$.detail.delete_dialog.cascade.notice_runtime_has_active_agents,
),
);
return;
}
throw err;
}
}
} catch (err) {
// Cascade-side plan-changed: the user confirmed plan A but the live
// set is now plan B. Refresh the displayed list with the server
// snapshot, force the user to re-tick the checkbox.
const conflict = parseActiveAgentsConflict(err);
if (conflict?.code === "runtime_delete_plan_changed") {
setPlanAgents(conflict.activeAgents);
setConfirmed(false);
setPlanChangedNotice(
t(
($) =>
$.detail.delete_dialog.cascade.notice_runtime_delete_plan_changed,
),
);
return;
}
const message =
err instanceof Error && err.message
? err.message
: t(($) => $.detail.delete_dialog.cascade.delete_failed_toast);
toast.error(message);
} finally {
setSubmitting(false);
}
};
// Permission to act — `false` blocks the close while submitting, prevents
// an accidental click on the underlying page from cancelling mid-write.
const handleOpenChange = (next: boolean) => {
if (submitting) return;
onOpenChange(next);
};
// Light mode keeps the legacy short copy. Cascade mode mirrors the plan
// 赵刚 wrote: destructive title with the count, a destructive warning
// banner, the agent table, then a checkbox confirm whose label restates
// the consequences in the same words as the warning.
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent
className={
cascade
? "w-[calc(100vw-2rem)] !max-w-[640px] gap-0 overflow-hidden rounded-lg p-0"
: "w-[calc(100vw-2rem)] !max-w-[440px] gap-0 overflow-hidden rounded-lg p-0"
}
onClick={(e) => e.stopPropagation()}
>
{cascade ? (
<CascadeBody
runtime={runtime}
agents={planAgents}
members={members}
presenceMap={presenceMap}
currentUserId={user?.id ?? null}
confirmed={confirmed}
onConfirmedChange={setConfirmed}
planChangedNotice={planChangedNotice}
submitting={submitting}
onCancel={() => handleOpenChange(false)}
onConfirm={handleConfirm}
/>
) : (
<LightBody
runtime={runtime}
submitting={submitting}
onCancel={() => handleOpenChange(false)}
onConfirm={handleConfirm}
/>
)}
</AlertDialogContent>
</AlertDialog>
);
}
// ---------------------------------------------------------------------------
// Light mode — no active agents, classic "are you sure" prompt. Title and
// description match the legacy AlertDialog so existing screenshots / muscle
// memory still apply.
// ---------------------------------------------------------------------------
function LightBody({
runtime,
submitting,
onCancel,
onConfirm,
}: {
runtime: AgentRuntime;
submitting: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useT("runtimes");
return (
<>
<div className="px-5 pb-4 pt-5">
<h2 className="text-base font-semibold">
{t(($) => $.detail.delete_dialog.light.title)}
</h2>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
{t(($) => $.detail.delete_dialog.light.description, {
name: runtime.name,
})}
</p>
</div>
<div className="border-t bg-muted/25 px-5 py-3">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={onCancel}
disabled={submitting}
>
{t(($) => $.detail.delete_dialog.light.cancel)}
</Button>
<Button
type="button"
variant="destructive"
className="w-full sm:w-auto"
onClick={onConfirm}
disabled={submitting}
>
{submitting
? t(($) => $.detail.delete_dialog.light.submitting)
: t(($) => $.detail.delete_dialog.light.confirm)}
</Button>
</div>
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Cascade mode — destructive warning, agent table, checkbox-confirmed
// destructive button. Copy follows 赵刚's English text verbatim per the
// squad lead's directive.
// ---------------------------------------------------------------------------
function CascadeBody({
runtime,
agents,
members,
presenceMap,
currentUserId,
confirmed,
onConfirmedChange,
planChangedNotice,
submitting,
onCancel,
onConfirm,
}: {
runtime: AgentRuntime;
agents: Agent[];
members: MemberWithUser[];
presenceMap: Map<string, AgentPresenceDetail>;
currentUserId: string | null;
confirmed: boolean;
onConfirmedChange: (next: boolean) => void;
planChangedNotice: string | null;
submitting: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useT("runtimes");
const count = agents.length;
return (
<>
<div className="px-5 pb-4 pt-5">
<h2 className="text-base font-semibold">
{t(($) => $.detail.delete_dialog.cascade.title, { count })}
</h2>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
{t(($) => $.detail.delete_dialog.cascade.description, {
name: runtime.name,
})}
</p>
{/* Destructive banner — keep the user's eye on the irreversible
half before they scan the agent table. */}
<div
role="alert"
className="mt-3 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive"
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{t(($) => $.detail.delete_dialog.cascade.warning)}</span>
</div>
{planChangedNotice && (
<div
role="status"
className="mt-2 rounded-md border bg-muted/40 px-3 py-2 text-xs text-foreground"
>
{planChangedNotice}
</div>
)}
<AgentPlanTable
agents={agents}
members={members}
presenceMap={presenceMap}
currentUserId={currentUserId}
/>
</div>
<div className="border-t bg-muted/25 px-5 py-4">
<label className="flex cursor-pointer items-start gap-2 text-sm text-foreground">
<Checkbox
className="mt-0.5"
checked={confirmed}
onCheckedChange={(next) => onConfirmedChange(next === true)}
disabled={submitting}
/>
<span className="leading-5">
{t(($) => $.detail.delete_dialog.cascade.checkbox, { count })}
</span>
</label>
<div className="mt-3 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={onCancel}
disabled={submitting}
>
{t(($) => $.detail.delete_dialog.cascade.cancel)}
</Button>
<Button
type="button"
variant="destructive"
className="w-full sm:w-auto"
onClick={onConfirm}
disabled={!confirmed || submitting}
>
{submitting
? t(($) => $.detail.delete_dialog.cascade.submitting)
: t(($) => $.detail.delete_dialog.cascade.confirm, { count })}
</Button>
</div>
</div>
</>
);
}
// AgentPlanTable renders the cascade plan: one row per agent showing the
// fields 赵刚 specified. Header + scroll body live in a fixed-height
// container so a runtime with many agents doesn't push the destructive
// confirm out of view; the body scrolls instead.
function AgentPlanTable({
agents,
members,
presenceMap,
currentUserId,
}: {
agents: Agent[];
members: MemberWithUser[];
presenceMap: Map<string, AgentPresenceDetail>;
currentUserId: string | null;
}) {
const { t } = useT("runtimes");
const memberById = useMemo(() => {
const map = new Map<string, MemberWithUser>();
for (const m of members) map.set(m.user_id, m);
return map;
}, [members]);
return (
<div className="mt-3 overflow-hidden rounded-md border">
<div className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,0.8fr)_minmax(0,1fr)] gap-3 border-b bg-muted/40 px-3 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
<span>{t(($) => $.detail.delete_dialog.cascade.table.header_agent)}</span>
<span>{t(($) => $.detail.delete_dialog.cascade.table.header_owner)}</span>
<span>{t(($) => $.detail.delete_dialog.cascade.table.header_status)}</span>
<span>
{t(($) => $.detail.delete_dialog.cascade.table.header_visibility)}
</span>
<span>{t(($) => $.detail.delete_dialog.cascade.table.header_model)}</span>
</div>
<div className="max-h-[240px] overflow-y-auto divide-y">
{agents.map((agent) => {
const ownerMember = agent.owner_id
? memberById.get(agent.owner_id) ?? null
: null;
const ownerLabel = ownerMember
? ownerMember.user_id === currentUserId
? t(($) => $.detail.delete_dialog.cascade.table.owner_self)
: ownerMember.name
: t(($) => $.detail.delete_dialog.cascade.table.owner_unassigned);
const presence = presenceMap.get(agent.id);
return (
<div
key={agent.id}
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-3 px-3 py-2 text-xs"
>
<span className="inline-flex min-w-0 items-center gap-2">
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={20}
enableHoverCard
/>
<span className="truncate font-medium text-foreground">
{agent.name}
</span>
</span>
<span className="inline-flex min-w-0 items-center gap-1.5">
{ownerMember ? (
<ActorAvatar
actorType="member"
actorId={ownerMember.user_id}
size={16}
/>
) : null}
<span className="truncate text-muted-foreground">
{ownerLabel}
</span>
</span>
<PresenceCell presence={presence} />
<VisibilityCell visibility={agent.visibility} />
<span className="truncate text-muted-foreground">
{agent.model ||
t(($) => $.detail.delete_dialog.cascade.table.model_unset)}
</span>
</div>
);
})}
</div>
</div>
);
}
function PresenceCell({ presence }: { presence: AgentPresenceDetail | undefined }) {
const { t } = useT("runtimes");
if (!presence) {
return (
<span className="text-muted-foreground/60">
{t(($) => $.detail.delete_dialog.cascade.table.presence_unknown)}
</span>
);
}
const av = availabilityConfig[presence.availability];
const wl = workloadConfig[presence.workload];
const counts =
presence.workload === "working"
? presence.queuedCount > 0
? `${presence.runningCount} +${presence.queuedCount}q`
: `${presence.runningCount}`
: presence.workload === "queued"
? `${presence.queuedCount}`
: null;
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className={`size-1.5 shrink-0 rounded-full ${av.dotClass}`} />
<span className={av.textClass}>
{presence.workload === "idle"
? t(($) => $.detail.delete_dialog.cascade.table.workload_idle)
: null}
{presence.workload === "working" && (
<wl.icon className={`mr-1 inline size-3 align-[-2px] animate-spin ${wl.textClass}`} />
)}
{presence.workload === "queued" && (
<wl.icon className={`mr-1 inline size-3 align-[-2px] ${wl.textClass}`} />
)}
{presence.workload === "working" &&
t(($) => $.detail.delete_dialog.cascade.table.workload_working)}
{presence.workload === "queued" &&
t(($) => $.detail.delete_dialog.cascade.table.workload_queued)}
</span>
{counts && (
<span className="font-mono tabular-nums text-muted-foreground/80">
{counts}
</span>
)}
</span>
);
}
function VisibilityCell({ visibility }: { visibility: string }) {
const { t } = useT("runtimes");
if (visibility === "public" || visibility === "workspace") {
return (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Globe className="size-3" />
<span>
{t(($) => $.detail.delete_dialog.cascade.table.visibility_workspace)}
</span>
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Lock className="size-3" />
<span>
{t(($) => $.detail.delete_dialog.cascade.table.visibility_private)}
</span>
</span>
);
}
// ---------------------------------------------------------------------------
// Server response parsing
// ---------------------------------------------------------------------------
interface ActiveAgentsConflict {
code: "runtime_has_active_agents" | "runtime_delete_plan_changed";
activeAgents: Agent[];
}
// parseActiveAgentsConflict pulls the structured 409 fields off an ApiError.
// Non-409s, non-active-agents codes, and missing bodies all collapse to
// `null` so callers can fall through to the generic error toast.
function parseActiveAgentsConflict(err: unknown): ActiveAgentsConflict | null {
if (!(err instanceof ApiError)) return null;
if (err.status !== 409) return null;
const body = err.body;
if (!body || typeof body !== "object") return null;
const code = (body as Record<string, unknown>).code;
if (
code !== "runtime_has_active_agents" &&
code !== "runtime_delete_plan_changed"
) {
return null;
}
const rawAgents = (body as Record<string, unknown>).active_agents;
if (!Array.isArray(rawAgents)) {
return { code, activeAgents: [] };
}
// We trust the server contract here — the response is the same
// AgentResponse shape that the agent list endpoint returns. Light
// runtime checks (id, runtime_id, name) catch genuinely malformed
// payloads without re-typing every field.
const activeAgents = rawAgents.filter(
(a): a is Agent =>
typeof a === "object" &&
a !== null &&
typeof (a as Record<string, unknown>).id === "string" &&
typeof (a as Record<string, unknown>).name === "string",
);
return { code, activeAgents };
}
@@ -16,17 +16,6 @@ import {
deriveRuntimeHealth,
runtimeUsageOptions,
} from "@multica/core/runtimes";
import { useDeleteRuntime } from "@multica/core/runtimes/mutations";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
@@ -44,6 +33,7 @@ import { useViewingTimezone } from "../../common/use-viewing-timezone";
import { workloadConfig } from "../../agents/presence";
import { ProviderLogo } from "./provider-logo";
import { HealthIcon, useHealthLabel } from "./shared";
import { DeleteRuntimeDialog } from "./delete-runtime-dialog";
import {
computeCostInWindow,
formatLastSeen,
@@ -484,7 +474,6 @@ function RowMenu({
canDelete: boolean;
}) {
const { t } = useT("runtimes");
const deleteMutation = useDeleteRuntime(wsId);
const [deleteOpen, setDeleteOpen] = useState(false);
// Delete is currently the only row action; if the row can't run it, drop
// the kebab entirely so the column doesn't render an empty popover. The
@@ -496,20 +485,6 @@ function RowMenu({
return <span aria-hidden />;
}
const handleDelete = () => {
deleteMutation.mutate(runtime.id, {
onSuccess: () => {
toast.success(t(($) => $.detail.toast_deleted));
setDeleteOpen(false);
},
onError: (e) => {
toast.error(
e instanceof Error ? e.message : t(($) => $.detail.toast_delete_failed),
);
},
});
};
return (
<>
<DropdownMenu>
@@ -541,35 +516,16 @@ function RowMenu({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog
<DeleteRuntimeDialog
open={deleteOpen}
onOpenChange={(v) => {
if (deleteMutation.isPending) return;
setDeleteOpen(v);
onOpenChange={setDeleteOpen}
runtime={runtime}
wsId={wsId}
onDeleted={() => {
setDeleteOpen(false);
toast.success(t(($) => $.detail.toast_deleted));
}}
>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.detail.delete_dialog.title)}</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.detail.delete_dialog.description, { name: runtime.name })}
<span className="mt-2 block text-xs text-muted-foreground/80">
{t(($) => $.list.delete_admin_hint)}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t(($) => $.detail.delete_dialog.cancel)}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? t(($) => $.detail.delete_dialog.deleting) : t(($) => $.detail.delete_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
/>
</>
);
}
@@ -22,7 +22,10 @@ vi.mock("@multica/core/hooks", () => ({
vi.mock("@multica/core/api", () => ({
api: {
updateRuntime: (...args: unknown[]) => mockUpdateRuntime(...args),
deleteRuntime: vi.fn(),
archiveAgentsAndDeleteRuntime: vi.fn(),
},
ApiError: class ApiError extends Error {},
}));
vi.mock("sonner", () => ({
@@ -80,7 +83,12 @@ vi.mock("@multica/core/runtimes/mutations", () => ({
},
isPending: false,
}),
useDeleteRuntime: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteRuntime: () => ({ mutate: vi.fn(), isPending: false, mutateAsync: vi.fn() }),
useArchiveAgentsAndDeleteRuntime: () => ({
mutate: vi.fn(),
isPending: false,
mutateAsync: vi.fn(),
}),
}));
// Stubbing ProviderLogo / UsageSection / UpdateSection avoids dragging in
@@ -15,7 +15,7 @@ import type { AgentRuntime, Agent, MemberWithUser } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useDeleteRuntime, useUpdateRuntime } from "@multica/core/runtimes/mutations";
import { useUpdateRuntime } from "@multica/core/runtimes/mutations";
import { deriveRuntimeHealth } from "@multica/core/runtimes";
import {
type AgentPresenceDetail,
@@ -23,16 +23,6 @@ import {
} from "@multica/core/agents";
import { useWorkspacePaths } from "@multica/core/paths";
import { Button } from "@multica/ui/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
@@ -46,6 +36,7 @@ import { HealthBadge } from "./shared";
import { ProviderLogo } from "./provider-logo";
import { UpdateSection } from "./update-section";
import { UsageSection } from "./usage-section";
import { DeleteRuntimeDialog } from "./delete-runtime-dialog";
import { useT } from "../../i18n";
function getCliVersion(metadata: Record<string, unknown>): string | null {
@@ -104,7 +95,6 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { byAgent: presenceMap } = useWorkspacePresenceMap(wsId);
const deleteMutation = useDeleteRuntime(wsId);
const now = useNowTick();
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -127,17 +117,13 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
(a) => a.runtime_id === runtime.id && !a.archived_at,
);
const handleDelete = () => {
deleteMutation.mutate(runtime.id, {
onSuccess: () => {
setDeleteOpen(false);
navigation.replace(paths.runtimes());
toast.success(t(($) => $.detail.toast_deleted));
},
onError: (e) => {
toast.error(e instanceof Error ? e.message : t(($) => $.detail.toast_delete_failed));
},
});
// Successful delete (light or cascade) closes the dialog and navigates
// back to the runtimes list. Toast lives here so the cascade-mode count
// and the light-mode "Runtime deleted" share one entry point.
const handleDeleted = () => {
setDeleteOpen(false);
navigation.replace(paths.runtimes());
toast.success(t(($) => $.detail.toast_deleted));
};
const daemonShort = shortDaemonId(runtime.daemon_id);
@@ -208,27 +194,16 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
</div>
</div>
{/* Delete confirmation */}
<AlertDialog open={deleteOpen} onOpenChange={(v) => { if (!v) setDeleteOpen(false); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.detail.delete_dialog.title)}</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.detail.delete_dialog.description, { name: runtime.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t(($) => $.detail.delete_dialog.cancel)}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? t(($) => $.detail.delete_dialog.deleting) : t(($) => $.detail.delete_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete confirmation — unified light/cascade dialog. Shared across
this page and the runtime list kebab so the two entry points stay
in lockstep on copy and behaviour. */}
<DeleteRuntimeDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
runtime={runtime}
wsId={wsId}
onDeleted={handleDeleted}
/>
</div>
);
}
@@ -28,7 +28,12 @@ vi.mock("@tanstack/react-query", async () => {
});
vi.mock("@multica/core/runtimes/mutations", () => ({
useDeleteRuntime: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteRuntime: () => ({ mutate: vi.fn(), isPending: false, mutateAsync: vi.fn() }),
useArchiveAgentsAndDeleteRuntime: () => ({
mutate: vi.fn(),
isPending: false,
mutateAsync: vi.fn(),
}),
}));
vi.mock("@multica/core/runtimes", () => ({
@@ -38,6 +43,27 @@ vi.mock("@multica/core/runtimes", () => ({
vi.mock("@multica/core/agents", () => ({
deriveWorkload: () => "idle",
useWorkspacePresenceMap: () => ({ byAgent: new Map(), loading: false }),
}));
// The unified DeleteRuntimeDialog the kebab now opens reaches into auth +
// the api singleton. The dialog never renders in these tests (`open=false`
// throughout) but its hooks still mount; stub them so module init is clean.
vi.mock("@multica/core/auth", () => ({
useAuthStore: (sel: (s: { user: { id: string } }) => unknown) =>
sel({ user: { id: "user-me" } }),
}));
vi.mock("@multica/core/api", () => ({
api: {
deleteRuntime: vi.fn(),
archiveAgentsAndDeleteRuntime: vi.fn(),
},
ApiError: class ApiError extends Error {},
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock("../../common/use-viewing-timezone", () => ({
+13 -12
View File
@@ -103,6 +103,7 @@ catalogs:
overrides:
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
'@xmldom/xmldom': ^0.8.13
importers:
@@ -419,7 +420,7 @@ importers:
version: 55.0.15([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
expo-router:
specifier: ~55.0.14
version: 55.0.14(fb4q2ydjsib3h2ixce2lptexem)
version: 55.0.14(28857138dad78e037ca803b5df54c4f8)
expo-secure-store:
specifier: ~55.0.13
version: 55.0.14([email protected])
@@ -4693,8 +4694,8 @@ packages:
'@vitest/[email protected]':
resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
'@xmldom/[email protected]2':
resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==}
'@xmldom/[email protected]3':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
[email protected]:
@@ -11828,7 +11829,7 @@ snapshots:
ws: 8.20.1
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.14(fb4q2ydjsib3h2ixce2lptexem)
expo-router: 55.0.14(28857138dad78e037ca803b5df54c4f8)
react-native: 0.83.6(@babel/[email protected])(@types/[email protected])([email protected])
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -11904,7 +11905,7 @@ snapshots:
ws: 8.20.1
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.14(sz7ge56yy7giy7pypish4aerfa)
expo-router: 55.0.14(8e96a26c61ba5d82f3cb5fb823dc6716)
react-native: 0.83.6(@babel/[email protected])(@types/[email protected])([email protected])
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -12134,7 +12135,7 @@ snapshots:
'@expo/[email protected]':
dependencies:
'@xmldom/xmldom': 0.8.12
'@xmldom/xmldom': 0.8.13
base64-js: 1.5.1
xmlbuilder: 15.1.1
@@ -12175,7 +12176,7 @@ snapshots:
react: 19.2.0
optionalDependencies:
'@expo/metro-runtime': 55.0.11(@expo/[email protected])([email protected])([email protected]([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
expo-router: 55.0.14(fb4q2ydjsib3h2ixce2lptexem)
expo-router: 55.0.14(28857138dad78e037ca803b5df54c4f8)
react-dom: 19.2.0([email protected])
transitivePeerDependencies:
- supports-color
@@ -12190,7 +12191,7 @@ snapshots:
react: 19.2.0
optionalDependencies:
'@expo/metro-runtime': 55.0.11(@expo/[email protected])([email protected])([email protected]([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
expo-router: 55.0.14(sz7ge56yy7giy7pypish4aerfa)
expo-router: 55.0.14(8e96a26c61ba5d82f3cb5fb823dc6716)
react-dom: 19.2.3([email protected])
transitivePeerDependencies:
- supports-color
@@ -15146,7 +15147,7 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@xmldom/[email protected]2': {}
'@xmldom/[email protected]3': {}
[email protected]: {}
@@ -17285,7 +17286,7 @@ snapshots:
optionalDependencies:
react-native-worklets: 0.7.4(@babel/[email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
[email protected](fb4q2ydjsib3h2ixce2lptexem):
[email protected](28857138dad78e037ca803b5df54c4f8):
dependencies:
'@expo/log-box': 55.0.12(@expo/[email protected])([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
'@expo/metro-runtime': 55.0.11(@expo/[email protected])([email protected])([email protected]([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
@@ -17333,7 +17334,7 @@ snapshots:
- expo-font
- supports-color
[email protected](sz7ge56yy7giy7pypish4aerfa):
[email protected](8e96a26c61ba5d82f3cb5fb823dc6716):
dependencies:
'@expo/log-box': 55.0.12(@expo/[email protected])([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
'@expo/metro-runtime': 55.0.11(@expo/[email protected])([email protected])([email protected]([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
@@ -20369,7 +20370,7 @@ snapshots:
[email protected]:
dependencies:
'@xmldom/xmldom': 0.8.12
'@xmldom/xmldom': 0.8.13
base64-js: 1.5.1
xmlbuilder: 15.1.1
+27 -4
View File
@@ -570,18 +570,41 @@ func runDaemonStatus(cmd *cobra.Command, _ []string) error {
return nil
}
fmt.Fprintf(os.Stdout, "%s: running (pid %v, uptime %v)\n", label, health["pid"], health["uptime"])
printDaemonStatusReport(os.Stdout, label, health)
return nil
}
// printDaemonStatusReport renders a key/value summary of the daemon health
// response. The value column is aligned to the widest label so the dynamic
// "Daemon [profile]" row stays in step with the static rows below it.
func printDaemonStatusReport(w io.Writer, label string, health map[string]any) {
type row struct{ key, value string }
rows := []row{
{label, fmt.Sprintf("running (pid %v, uptime %v)", health["pid"], health["uptime"])},
}
if version, ok := health["cli_version"].(string); ok && version != "" {
rows = append(rows, row{"Version", version})
}
if agents, ok := health["agents"].([]any); ok && len(agents) > 0 {
parts := make([]string, len(agents))
for i, a := range agents {
parts[i] = fmt.Sprint(a)
}
fmt.Fprintf(os.Stdout, "Agents: %s\n", strings.Join(parts, ", "))
rows = append(rows, row{"Agents", strings.Join(parts, ", ")})
}
if ws, ok := health["workspaces"].([]any); ok {
fmt.Fprintf(os.Stdout, "Workspaces: %d\n", len(ws))
rows = append(rows, row{"Workspaces", strconv.Itoa(len(ws))})
}
keyWidth := 0
for _, r := range rows {
if n := len(r.key); n > keyWidth {
keyWidth = n
}
}
for _, r := range rows {
fmt.Fprintf(w, "%-*s %s\n", keyWidth+1, r.key+":", r.value)
}
return nil
}
// --- daemon logs ---
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestPrintDaemonStatusIncludesCLIVersion(t *testing.T) {
t.Parallel()
health := map[string]any{
"status": "running",
"pid": float64(1234),
"uptime": "1h2m3s",
"cli_version": "v9.9.9",
"agents": []any{"codex"},
"workspaces": []any{map[string]any{"id": "ws-1"}},
}
var out bytes.Buffer
printDaemonStatusReport(&out, "Daemon", health)
got := out.String()
if !strings.Contains(got, "Version: v9.9.9\n") {
t.Fatalf("daemon status output = %q, want CLI version line", got)
}
}
// TestPrintDaemonStatusOmitsVersionWhenMissing pins the back-compat contract:
// when the daemon doesn't report cli_version (older daemon paired with a newer
// CLI) or reports an empty string, the CLI must skip the line entirely instead
// of printing "Version: ".
func TestPrintDaemonStatusOmitsVersionWhenMissing(t *testing.T) {
t.Parallel()
cases := map[string]map[string]any{
"key missing": {
"status": "running",
"pid": float64(1234),
"uptime": "1h2m3s",
"workspaces": []any{},
},
"empty string": {
"status": "running",
"pid": float64(1234),
"uptime": "1h2m3s",
"cli_version": "",
"workspaces": []any{},
},
}
for name, health := range cases {
health := health
t.Run(name, func(t *testing.T) {
t.Parallel()
var out bytes.Buffer
printDaemonStatusReport(&out, "Daemon", health)
if strings.Contains(out.String(), "Version:") {
t.Fatalf("daemon status output = %q, want no Version line", out.String())
}
})
}
}
// TestPrintDaemonStatusAlignsValuesWithProfileLabel guards the alignment fix:
// before, a "Daemon [profile]" label was wider than the other keys, so the
// Daemon row's value started further right than every subsequent row. The
// report now pads every key to the widest one, so the value column lines up.
func TestPrintDaemonStatusAlignsValuesWithProfileLabel(t *testing.T) {
t.Parallel()
health := map[string]any{
"status": "running",
"pid": float64(1234),
"uptime": "1h2m3s",
"cli_version": "v9.9.9",
"agents": []any{"codex"},
"workspaces": []any{map[string]any{"id": "ws-1"}},
}
var out bytes.Buffer
printDaemonStatusReport(&out, "Daemon [staging]", health)
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
if len(lines) < 2 {
t.Fatalf("expected multiple lines, got %q", out.String())
}
// Find the column where each row's value starts (first non-space after
// the colon). Every row must share the same column.
want := valueColumn(t, lines[0])
for _, line := range lines[1:] {
if got := valueColumn(t, line); got != want {
t.Fatalf("value column drift: line %q starts at col %d, want %d (first line: %q)",
line, got, want, lines[0])
}
}
}
func valueColumn(t *testing.T, line string) int {
t.Helper()
colon := strings.Index(line, ":")
if colon < 0 {
t.Fatalf("line missing colon: %q", line)
}
for i := colon + 1; i < len(line); i++ {
if line[i] != ' ' {
return i
}
}
t.Fatalf("line missing value: %q", line)
return 0
}
+4 -2
View File
@@ -124,8 +124,10 @@ var issueAssignCmd = &cobra.Command{
var issueStatusCmd = &cobra.Command{
Use: "status <id> <status>",
Short: "Change issue status",
Args: exactArgs(2),
RunE: runIssueStatus,
Long: "Change an issue's status. Valid statuses: " +
"backlog, todo, in_progress, in_review, done, blocked, cancelled.",
Args: exactArgs(2),
RunE: runIssueStatus,
}
// Comment subcommands.
+7
View File
@@ -599,6 +599,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Post("/local-skills/import", h.InitiateImportLocalSkill)
r.Get("/local-skills/import/{requestId}", h.GetLocalSkillImportRequest)
r.Delete("/", h.DeleteAgentRuntime)
// Cascade variant of DELETE: archive every active agent
// bound to this runtime, cancel their tasks, then delete
// the runtime — all in one transaction. Used by the
// DeleteRuntimeDialog when the strict DELETE refused with
// `runtime_has_active_agents` and the user confirmed the
// cascade plan.
r.Post("/archive-agents-and-delete", h.ArchiveAgentsAndDeleteRuntime)
})
})
-11
View File
@@ -2432,10 +2432,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
if provider == "openclaw" {
openclawBin = entry.Path
}
envSkillsLocal := ""
if task.Agent != nil {
envSkillsLocal = task.Agent.SkillsLocal
}
// Resolve any local_directory assignment again here so runTask can plumb
// LocalWorkDir into execenv. handleTask already validated + locked the
// path; this call is a pure JSON parse over the same task payload.
@@ -2450,7 +2446,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
Provider: provider,
CodexVersion: codexVersion,
OpenclawBin: openclawBin,
SkillsLocal: envSkillsLocal,
Task: taskCtx,
}, d.logger)
}
@@ -2464,7 +2459,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
Provider: provider,
CodexVersion: codexVersion,
OpenclawBin: openclawBin,
SkillsLocal: envSkillsLocal,
Task: taskCtx,
}
if localAssignment != nil {
@@ -2655,10 +2649,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
thinkingLevel = ""
}
}
skillsLocal := ""
if task.Agent != nil {
skillsLocal = task.Agent.SkillsLocal
}
execOpts := agent.ExecOptions{
Cwd: env.WorkDir,
Model: model,
@@ -2669,7 +2659,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
CustomArgs: customArgs,
McpConfig: mcpConfig,
ThinkingLevel: thinkingLevel,
SkillsLocal: skillsLocal,
}
// Some providers do not reliably load the per-task runtime config files we
// write into the task workdir:
+40 -11
View File
@@ -86,12 +86,12 @@ func prepareCodexHomeWithOpts(codexHome string, opts CodexHomeOptions, logger *s
// into a stale local copy.
logCodexAuthState(filepath.Join(codexHome, "auth.json"), logger)
// Copy config files (isolated per task).
// Sync config files from the shared source (isolated per task).
for _, name := range codexCopiedFiles {
src := filepath.Join(sharedHome, name)
dst := filepath.Join(codexHome, name)
if err := copyFileIfExists(src, dst); err != nil {
logger.Warn("execenv: codex-home copy failed", "file", name, "error", err)
if err := syncCopiedFile(src, dst); err != nil {
logger.Warn("execenv: codex-home sync failed", "file", name, "error", err)
}
}
@@ -271,18 +271,47 @@ func logCodexAuthState(authPath string, logger *slog.Logger) {
// codex_sandbox.go's ensureCodexSandboxConfig so they can be updated
// idempotently without touching user-managed keys.)
// copyFileIfExists copies src to dst. If src doesn't exist, it's a no-op.
// If dst already exists, it's not overwritten.
func copyFileIfExists(src, dst string) error {
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil
// syncCopiedFile mirrors a per-task dst onto the current state of the shared
// src so the per-task copy tracks the shared source across Reuse() runs:
//
// - src present, dst absent: copy src → dst
// - src present, dst present: drop dst and re-copy src → dst (refresh)
// - src absent, dst present: drop dst (the shared source has been removed,
// so the per-task stale copy must not linger)
// - src absent, dst absent: no-op
//
// Regression for MUL-2646: the prior "don't overwrite" guard left per-task
// config.toml / config.json / instructions.md stuck on whatever snapshot they
// were seeded with at first Prepare. A user who edited ~/.codex/config.toml
// between runs — switching the active [model_providers.X] base_url, pointing
// env_key at a freshly rotated API key, or removing the file outright to
// drop a provider — kept hitting the stale per-task copy on session resume,
// with Codex calling the new URL using the old key (or replaying a provider
// the user had since deleted from the shared config).
//
// For config.toml the subsequent ensureCodex{Sandbox,MultiAgent,Memory}Config
// passes recreate the file from scratch when the shared source is gone, so
// the per-task home keeps the daemon-managed defaults but loses every
// user-managed [model_providers.X] / model_provider line that no longer
// exists in the shared config. For config.json / instructions.md there is
// no daemon-managed default, so they simply disappear in lockstep with the
// shared source.
func syncCopiedFile(src, dst string) error {
_, srcErr := os.Stat(src)
srcMissing := os.IsNotExist(srcErr)
if srcErr != nil && !srcMissing {
return fmt.Errorf("stat src %s: %w", src, srcErr)
}
// Don't overwrite existing file.
if _, err := os.Stat(dst); err == nil {
return nil
if _, err := os.Lstat(dst); err == nil {
if err := os.Remove(dst); err != nil {
return fmt.Errorf("remove stale dst %s: %w", dst, err)
}
}
if srcMissing {
return nil
}
return copyFile(src, dst)
}
+9 -30
View File
@@ -39,13 +39,6 @@ type PrepareParams struct {
Provider string // agent provider (determines runtime config and skill injection paths)
CodexVersion string // detected Codex CLI version (only used when Provider == "codex")
OpenclawBin string // resolved openclaw CLI path (only used when Provider == "openclaw"); empty = look up on PATH
// SkillsLocal mirrors agent.SkillsLocal. "ignore" instructs runtime
// setup steps that the daemon controls (currently Codex's user-skill
// seed) to skip injecting the host machine's user-global skills into
// the per-task environment. Any other value — including "merge" and
// the empty string from older servers / clients — preserves the
// inherit-from-host behavior.
SkillsLocal string
// LocalWorkDir, when non-empty, redirects the agent's working directory
// to a user-supplied absolute path instead of the synthesised envRoot/
// workdir. The path is NOT copied or mounted — the agent operates on
@@ -207,7 +200,7 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
if err := prepareCodexHomeWithOpts(codexHome, CodexHomeOptions{CodexVersion: params.CodexVersion}, logger); err != nil {
return nil, fmt.Errorf("execenv: prepare codex-home: %w", err)
}
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, params.SkillsLocal, logger); err != nil {
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, logger); err != nil {
return nil, fmt.Errorf("execenv: hydrate codex skills: %w", err)
}
env.CodexHome = codexHome
@@ -240,11 +233,6 @@ type ReuseParams struct {
Provider string
CodexVersion string // only used when Provider == "codex"
OpenclawBin string // only used when Provider == "openclaw"; empty = PATH lookup
// SkillsLocal mirrors PrepareParams.SkillsLocal. Reuse must respect
// the same gate so a workdir resurrected on a later task picks up the
// current agent setting (the user may have flipped the toggle between
// runs).
SkillsLocal string
// LocalDirectory is true when the reused WorkDir is a user-supplied
// directory (the local_directory flow). The flag is propagated into
// the returned Environment so downstream callers (notably the GC
@@ -295,7 +283,7 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment {
logger.Warn("execenv: refresh codex-home failed", "error", err)
} else {
env.CodexHome = codexHome
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, params.SkillsLocal, logger); err != nil {
if err := hydrateCodexSkills(codexHome, params.Task.AgentSkills, logger); err != nil {
logger.Warn("execenv: refresh codex skills failed", "error", err)
}
}
@@ -336,26 +324,17 @@ func Reuse(params ReuseParams, logger *slog.Logger) *Environment {
// run — its old contents would otherwise remain visible to the codex
// CLI.
//
// When skillsLocal == "ignore" the user-skill seed is skipped, so the
// per-task CODEX_HOME exposes only workspace skills to the codex CLI. This
// is the Codex-side enforcement of the per-agent `skills_local` toggle —
// the Claude backend enforces the same contract by mirroring `~/.claude/`
// minus `skills/` (see server/pkg/agent/claude.go).
//
// Codex is the only runtime besides Claude where the daemon actively
// manages user-skill discovery: Codex via CODEX_HOME and the seed step
// here, Claude via CLAUDE_CONFIG_DIR scratch mirroring. Other runtimes
// leave HOME untouched and discover user-level skills natively (see
// context.go for the workdir-local paths they use for workspace skills),
// so `skills_local` is a no-op for them today.
func hydrateCodexSkills(codexHome string, workspaceSkills []SkillContextForEnv, skillsLocal string, logger *slog.Logger) error {
// Codex is the only runtime that needs this two-stage hydration because the
// daemon sets CODEX_HOME to a per-task directory, isolating the CLI from the
// user's real ~/.codex/. Other runtimes leave HOME untouched and discover
// user-level skills natively (see context.go for the workdir-local paths
// they use for workspace skills).
func hydrateCodexSkills(codexHome string, workspaceSkills []SkillContextForEnv, logger *slog.Logger) error {
skillsDir := filepath.Join(codexHome, "skills")
if err := os.RemoveAll(skillsDir); err != nil {
return fmt.Errorf("clear codex skills dir: %w", err)
}
if skillsLocal == "ignore" {
logger.Info("execenv: codex user-skill seed skipped (skills_local=ignore)")
} else if err := seedUserCodexSkills(codexHome, workspaceSkills, logger); err != nil {
if err := seedUserCodexSkills(codexHome, workspaceSkills, logger); err != nil {
logger.Warn("execenv: seed user codex skills failed", "error", err)
}
if len(workspaceSkills) == 0 {
+194 -139
View File
@@ -1751,6 +1751,200 @@ func TestPrepareCodexHome_RefreshesStaleAuthCopyOnReuse(t *testing.T) {
}
}
// Regression for MUL-2646: when the user updates `~/.codex/config.toml`
// between two task runs against the same per-task codex-home — e.g. to
// rotate the active [model_providers.X] base_url or point env_key at a
// new API key — the per-task copy must refresh from the shared source on
// Reuse(). Without this, Codex keeps reading the old provider URL / env
// var on session resume, so the agent hits the new endpoint with the old
// key and the API rejects the token. Symmetric to issue #2081's fix for
// the symlinked auth.json (covered above).
func TestPrepareCodexHome_RefreshesStaleCopiedConfigOnReuse(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
oldConfig := `model_provider = "old-provider"
[model_providers.old-provider]
name = "Old"
base_url = "https://old.example.com"
env_key = "OLD_API_KEY"
`
if err := os.WriteFile(filepath.Join(sharedHome, "config.toml"), []byte(oldConfig), 0o644); err != nil {
t.Fatalf("seed shared config.toml: %v", err)
}
if err := os.WriteFile(filepath.Join(sharedHome, "config.json"), []byte(`{"model":"old-model"}`), 0o644); err != nil {
t.Fatalf("seed shared config.json: %v", err)
}
if err := os.WriteFile(filepath.Join(sharedHome, "instructions.md"), []byte("old instructions"), 0o644); err != nil {
t.Fatalf("seed shared instructions.md: %v", err)
}
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("first prepareCodexHome: %v", err)
}
// User rotates provider + API key in the shared config between runs.
newConfig := `model_provider = "new-provider"
[model_providers.new-provider]
name = "New"
base_url = "https://new.example.com"
env_key = "NEW_API_KEY"
`
if err := os.WriteFile(filepath.Join(sharedHome, "config.toml"), []byte(newConfig), 0o644); err != nil {
t.Fatalf("rotate shared config.toml: %v", err)
}
if err := os.WriteFile(filepath.Join(sharedHome, "config.json"), []byte(`{"model":"new-model"}`), 0o644); err != nil {
t.Fatalf("rotate shared config.json: %v", err)
}
if err := os.WriteFile(filepath.Join(sharedHome, "instructions.md"), []byte("new instructions"), 0o644); err != nil {
t.Fatalf("rotate shared instructions.md: %v", err)
}
// Resume path: same per-task codex-home, re-prepared.
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("second prepareCodexHome (resume): %v", err)
}
// config.toml must reflect the new provider/URL/env_key.
data, err := os.ReadFile(filepath.Join(codexHome, "config.toml"))
if err != nil {
t.Fatalf("read per-task config.toml: %v", err)
}
s := string(data)
for _, want := range []string{`model_provider = "new-provider"`, "https://new.example.com", "NEW_API_KEY"} {
if !strings.Contains(s, want) {
t.Errorf("per-task config.toml missing %q after refresh, got:\n%s", want, s)
}
}
for _, bad := range []string{"old-provider", "https://old.example.com", "OLD_API_KEY"} {
if strings.Contains(s, bad) {
t.Errorf("per-task config.toml still contains stale %q after refresh, got:\n%s", bad, s)
}
}
// Daemon-managed sandbox / multi-agent / memory blocks must all be
// re-applied on top of the fresh copy — PR correctness depends on it.
for _, marker := range []string{
multicaManagedBeginMarker,
multicaMultiAgentBeginMarker,
multicaMemoryFeatureBeginMarker,
multicaMemoryConfigBeginMarker,
} {
if !strings.Contains(s, marker) {
t.Errorf("daemon-managed marker %q missing after refresh, got:\n%s", marker, s)
}
}
// config.json must reflect the new model.
data, err = os.ReadFile(filepath.Join(codexHome, "config.json"))
if err != nil {
t.Fatalf("read per-task config.json: %v", err)
}
if string(data) != `{"model":"new-model"}` {
t.Errorf("per-task config.json content = %q, want refreshed contents", data)
}
// instructions.md must reflect the new content.
data, err = os.ReadFile(filepath.Join(codexHome, "instructions.md"))
if err != nil {
t.Fatalf("read per-task instructions.md: %v", err)
}
if string(data) != "new instructions" {
t.Errorf("per-task instructions.md content = %q, want refreshed contents", data)
}
}
// Regression for MUL-2646 (deletion arm): when the user removes a file from
// the shared ~/.codex/ between two task runs — for example by dropping the
// whole `~/.codex/config.toml`, removing `config.json`, or deleting
// `instructions.md` — the per-task copy must be dropped too, otherwise
// session resume keeps replaying a provider / instruction file the user has
// already removed from the shared config. For config.toml the subsequent
// daemon-managed ensure* passes recreate a minimal file with only the
// managed sandbox / multi-agent / memory blocks; for config.json and
// instructions.md the per-task copy simply disappears.
func TestPrepareCodexHome_DropsCopiedConfigWhenSharedSourceRemoved(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
oldConfig := `model_provider = "old-provider"
[model_providers.old-provider]
name = "Old"
base_url = "https://old.example.com"
env_key = "OLD_API_KEY"
`
if err := os.WriteFile(filepath.Join(sharedHome, "config.toml"), []byte(oldConfig), 0o644); err != nil {
t.Fatalf("seed shared config.toml: %v", err)
}
if err := os.WriteFile(filepath.Join(sharedHome, "config.json"), []byte(`{"model":"old-model"}`), 0o644); err != nil {
t.Fatalf("seed shared config.json: %v", err)
}
if err := os.WriteFile(filepath.Join(sharedHome, "instructions.md"), []byte("old instructions"), 0o644); err != nil {
t.Fatalf("seed shared instructions.md: %v", err)
}
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("first prepareCodexHome: %v", err)
}
// Sanity: first prepare seeded all three files into the per-task home.
for _, name := range []string{"config.toml", "config.json", "instructions.md"} {
if _, err := os.Stat(filepath.Join(codexHome, name)); err != nil {
t.Fatalf("first prepare did not seed per-task %s: %v", name, err)
}
}
// User removes the shared sources between runs.
for _, name := range []string{"config.toml", "config.json", "instructions.md"} {
if err := os.Remove(filepath.Join(sharedHome, name)); err != nil {
t.Fatalf("remove shared %s: %v", name, err)
}
}
// Resume path: same per-task codex-home, re-prepared.
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("second prepareCodexHome (resume): %v", err)
}
// config.json and instructions.md have no daemon-managed default — they
// must disappear in lockstep with the shared source.
for _, name := range []string{"config.json", "instructions.md"} {
if _, err := os.Stat(filepath.Join(codexHome, name)); !os.IsNotExist(err) {
t.Errorf("per-task %s still exists after shared source removed (stat err = %v)", name, err)
}
}
// config.toml must still exist because the ensure* passes recreate it,
// but it must contain only the daemon-managed blocks — no stale user
// provider/URL/env_key.
data, err := os.ReadFile(filepath.Join(codexHome, "config.toml"))
if err != nil {
t.Fatalf("read per-task config.toml after shared removal: %v", err)
}
s := string(data)
for _, bad := range []string{"old-provider", "https://old.example.com", "OLD_API_KEY"} {
if strings.Contains(s, bad) {
t.Errorf("per-task config.toml still contains stale %q after shared source removed, got:\n%s", bad, s)
}
}
for _, marker := range []string{
multicaManagedBeginMarker,
multicaMultiAgentBeginMarker,
multicaMemoryFeatureBeginMarker,
multicaMemoryConfigBeginMarker,
} {
if !strings.Contains(s, marker) {
t.Errorf("daemon-managed marker %q missing after shared source removed, got:\n%s", marker, s)
}
}
}
func TestEnsureCodexSandboxConfigCreatesDefaultLinux(t *testing.T) {
t.Parallel()
dir := t.TempDir()
@@ -2456,145 +2650,6 @@ func TestPrepareCodexResolvesUserSkillSymlinks(t *testing.T) {
}
}
// TestPrepareCodexSkillsLocalIgnoreSkipsUserSeed covers the per-agent
// `skills_local=ignore` contract on the Codex side (MUL-2603): when the
// agent opts out of host-machine skills, the daemon must not seed
// `~/.codex/skills/` into the per-task CODEX_HOME, so a broken local skill
// cannot crash a shared Codex agent. The default (`merge`) preserves the
// pre-existing seed-from-shared behavior — covered by
// TestPrepareCodexSeedsUserSkills above.
func TestPrepareCodexSkillsLocalIgnoreSkipsUserSeed(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkills := filepath.Join(sharedHome, "skills")
if err := os.MkdirAll(filepath.Join(userSkills, "summarize"), 0o755); err != nil {
t.Fatalf("seed user skill dir: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, "summarize", "SKILL.md"), []byte("user summarize"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-codex-skills-local-ignore",
TaskID: "c1d2e3f4-a5b6-7890-cdef-123456789012",
AgentName: "Codex Agent",
Provider: "codex",
SkillsLocal: "ignore",
Task: TaskContextForEnv{
IssueID: "codex-skills-local-ignore-test",
AgentSkills: []SkillContextForEnv{
{Name: "Writing", Content: "workspace writing"},
},
},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", "summarize")); !os.IsNotExist(err) {
t.Errorf("user skill seeded despite skills_local=ignore: err=%v", err)
}
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", "writing", "SKILL.md")); err != nil {
t.Errorf("workspace skill not written under skills_local=ignore: %v", err)
}
}
// TestPrepareCodexSkillsLocalMergeSeedsUserSkills locks in the default
// `merge` semantics on the Codex side: anything other than the literal
// "ignore" (including empty from older servers) preserves the seed-from-
// shared behavior so existing personal workflows continue working.
func TestPrepareCodexSkillsLocalMergeSeedsUserSkills(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkills := filepath.Join(sharedHome, "skills")
if err := os.MkdirAll(filepath.Join(userSkills, "translate"), 0o755); err != nil {
t.Fatalf("seed user skill dir: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, "translate", "SKILL.md"), []byte("user translate"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-codex-skills-local-merge",
TaskID: "d2e3f4a5-b6c7-8901-cdef-234567890123",
AgentName: "Codex Agent",
Provider: "codex",
SkillsLocal: "merge",
Task: TaskContextForEnv{IssueID: "codex-skills-local-merge-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
data, err := os.ReadFile(filepath.Join(env.CodexHome, "skills", "translate", "SKILL.md"))
if err != nil {
t.Fatalf("user skill not seeded under skills_local=merge: %v", err)
}
if string(data) != "user translate" {
t.Errorf("seeded user SKILL.md = %q, want %q", data, "user translate")
}
}
// TestReuseCodexSkillsLocalIgnoreSkipsUserSeed mirrors the Prepare path on
// the Reuse branch: if a user flips the agent toggle to `ignore` between
// runs, the next task on the reused workdir must drop the previously-seeded
// user skill rather than leak it through.
func TestReuseCodexSkillsLocalIgnoreSkipsUserSeed(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
userSkills := filepath.Join(sharedHome, "skills")
if err := os.MkdirAll(filepath.Join(userSkills, "summarize"), 0o755); err != nil {
t.Fatalf("seed user skill dir: %v", err)
}
if err := os.WriteFile(filepath.Join(userSkills, "summarize", "SKILL.md"), []byte("user summarize"), 0o644); err != nil {
t.Fatalf("seed user SKILL.md: %v", err)
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: t.TempDir(),
WorkspaceID: "ws-codex-reuse-toggle",
TaskID: "e3f4a5b6-c7d8-9012-cdef-345678901234",
AgentName: "Codex Agent",
Provider: "codex",
SkillsLocal: "merge",
Task: TaskContextForEnv{IssueID: "codex-reuse-toggle-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if _, err := os.Stat(filepath.Join(env.CodexHome, "skills", "summarize", "SKILL.md")); err != nil {
t.Fatalf("Prepare(merge) did not seed user skill: %v", err)
}
reused := Reuse(ReuseParams{
WorkDir: env.WorkDir,
Provider: "codex",
SkillsLocal: "ignore",
Task: TaskContextForEnv{IssueID: "codex-reuse-toggle-test"},
}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
if _, err := os.Stat(filepath.Join(reused.CodexHome, "skills", "summarize")); !os.IsNotExist(err) {
t.Errorf("user skill survived Reuse(skills_local=ignore): err=%v", err)
}
}
// TestReuseSeedsUserSkillUpdates ensures that user-skill edits between two
// runs of the same task (the Reuse path) propagate into the per-task home.
func TestReuseSeedsUserSkillUpdates(t *testing.T) {
+3 -1
View File
@@ -116,7 +116,9 @@ func classifyResumeUnsafeTimeout(provider, errMsg string) (string, bool) {
if strings.ToLower(strings.TrimSpace(provider)) != "codex" || errMsg == "" {
return "", false
}
if strings.Contains(strings.ToLower(errMsg), strings.ToLower(agent.CodexSemanticInactivityMarker)) {
lowered := strings.ToLower(errMsg)
if strings.Contains(lowered, strings.ToLower(agent.CodexSemanticInactivityMarker)) ||
strings.Contains(lowered, strings.ToLower(agent.CodexFirstTurnNoProgressMarker)) {
return FailureReasonCodexSemanticInactivity, true
}
return "", false
+7
View File
@@ -187,6 +187,13 @@ func TestClassifyResumeUnsafeTimeout(t *testing.T) {
wantOK: true,
wantReason: FailureReasonCodexSemanticInactivity,
},
{
name: "codex first turn no progress",
provider: "codex",
errMsg: agent.CodexFirstTurnNoProgressMarker + ` after 30s: received turn start but no item, turn/completed, or error event`,
wantOK: true,
wantReason: FailureReasonCodexSemanticInactivity,
},
{
name: "codex ordinary timeout remains resumable",
provider: "codex",
-12
View File
@@ -105,18 +105,6 @@ type AgentData struct {
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"`
// SkillsLocal controls whether the runtime merges the host machine's
// user-global skill directory into the agent. "merge" (default)
// preserves the inherit-from-machine behavior; "ignore" hides the
// directory from the runtime so a broken local skill cannot crash a
// shared agent (#3052). Old servers that predate the column omit this
// field entirely; the daemon treats empty as "merge". Currently
// honoured by Claude (via CLAUDE_CONFIG_DIR scratch mirroring in
// server/pkg/agent/claude.go) and Codex (via the user-skill seed gate
// in server/internal/daemon/execenv/execenv.go); other runtimes leave
// HOME untouched and natively discover user-level skills, so the
// toggle is a no-op for them.
SkillsLocal string `json:"skills_local,omitempty"`
}
// SkillData represents a structured skill for task execution.
+2 -72
View File
@@ -56,15 +56,8 @@ type AgentResponse struct {
// ThinkingLevel is the runtime-native reasoning/effort token persisted
// for this agent (empty = use runtime default). The picker is per-runtime
// per-model; the API never normalizes across providers. See MUL-2339.
ThinkingLevel string `json:"thinking_level"`
// SkillsLocal controls whether the runtime merges the host machine's
// user-global skill directory (e.g. Claude's `~/.claude/skills/`) into
// the agent's skill set. "merge" (default) preserves the pre-existing
// inherit-from-machine behavior; "ignore" isolates the runtime so a
// broken local skill cannot crash a shared agent (#3052). Workspace
// skills (`{workDir}/.claude/skills/`) are always loaded regardless.
SkillsLocal string `json:"skills_local"`
OwnerID *string `json:"owner_id"`
ThinkingLevel string `json:"thinking_level"`
OwnerID *string `json:"owner_id"`
Skills []AgentSkillSummary `json:"skills"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
@@ -129,7 +122,6 @@ func agentToResponse(a db.Agent) AgentResponse {
MaxConcurrentTasks: a.MaxConcurrentTasks,
Model: a.Model.String,
ThinkingLevel: a.ThinkingLevel.String,
SkillsLocal: normalizeSkillsLocal(a.SkillsLocal),
OwnerID: uuidToPtr(a.OwnerID),
Skills: []AgentSkillSummary{},
CreatedAt: timestampToString(a.CreatedAt),
@@ -253,11 +245,6 @@ type TaskAgentData struct {
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"`
// SkillsLocal mirrors the agent column of the same name. Daemon reads
// it to decide whether to isolate the Claude runtime from the host's
// user-global `~/.claude/skills/`. Empty string is treated as the
// platform default ("merge") by the daemon — see normalizeSkillsLocal.
SkillsLocal string `json:"skills_local,omitempty"`
}
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
@@ -475,11 +462,6 @@ type CreateAgentRequest struct {
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
Model string `json:"model"`
ThinkingLevel string `json:"thinking_level"`
// SkillsLocal opts the agent into ("merge") or out of ("ignore") the
// runtime's user-global skill discovery. Empty / missing defaults to
// "merge". Validated in CreateAgent / UpdateAgent against the
// recognised set; anything else returns 400.
SkillsLocal string `json:"skills_local"`
// Template records which template slug was used to seed this agent
// (e.g. "coding" / "planning" / "writing" / "assistant"). Empty when
// the caller didn't come from a template picker — the `agent_created`
@@ -579,21 +561,6 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
return
}
// Request validation is strict: a missing field defaults to "merge" (the
// pre-MUL-2603 inherit-from-machine behavior), but a present-but-bogus
// value must 400 rather than silently normalize. The drift-safe normalize
// is only for on-read coercion of stored values (normalizeSkillsLocal) —
// applying it to request input would mask client typos as a successful
// create.
skillsLocal := "merge"
if req.SkillsLocal != "" {
if !isValidSkillsLocal(req.SkillsLocal) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("skills_local %q is invalid (expected \"ignore\" or \"merge\")", req.SkillsLocal))
return
}
skillsLocal = req.SkillsLocal
}
// Probe workspace agent count BEFORE the insert so the funnel has a
// clean "first agent ever in this workspace" signal — Step 4 of
// onboarding always lands in this branch. A non-fatal read: if the
@@ -641,7 +608,6 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
McpConfig: mc,
Model: pgtype.Text{String: req.Model, Valid: req.Model != ""},
ThinkingLevel: pgtype.Text{String: req.ThinkingLevel, Valid: req.ThinkingLevel != ""},
SkillsLocal: skillsLocal,
})
if err != nil {
// Unique constraint on (workspace_id, name) — return a clear conflict error
@@ -709,31 +675,6 @@ type UpdateAgentRequest struct {
// Distinguishing those modes is why this is a pointer; the raw-fields
// map captured at decode time tells us whether the key was sent.
ThinkingLevel *string `json:"thinking_level"`
// SkillsLocal: nil means "no change"; non-nil overrides the stored
// value after validation. We use a pointer (rather than the empty
// string sentinel ThinkingLevel uses) because there is no "clear"
// state — the column has a NOT NULL default and the toggle is binary.
SkillsLocal *string `json:"skills_local"`
}
// normalizeSkillsLocal coerces empty / unknown stored values to the platform
// default "merge" — the pre-MUL-2603 inherit-from-machine behavior. Older
// rows written before the column existed get a NOT NULL default at migration
// time, but the helper still guards against any future schema drift or
// hand-edited rows so we never surface an in-between state to clients or the
// daemon.
func normalizeSkillsLocal(stored string) string {
if stored == "ignore" {
return "ignore"
}
return "merge"
}
// isValidSkillsLocal mirrors the CHECK constraint on agent.skills_local.
// The DB would reject a write with a 23514 violation anyway, but pre-flight
// validation lets the API return a clean 400 with the offending value.
func isValidSkillsLocal(v string) bool {
return v == "ignore" || v == "merge"
}
// workspaceAlwaysRedactSecrets reports whether the workspace has opted
@@ -935,17 +876,6 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
if req.Model != nil {
params.Model = pgtype.Text{String: *req.Model, Valid: true}
}
if req.SkillsLocal != nil {
// PATCH is strictly opt-in: nil means no change. A non-nil value must
// validate as-is — normalizing here would let garbage silently land as
// "merge" and hide a client bug. The drift-safe coercion lives in
// normalizeSkillsLocal for on-read use only.
if !isValidSkillsLocal(*req.SkillsLocal) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("skills_local %q is invalid (expected \"ignore\" or \"merge\")", *req.SkillsLocal))
return
}
params.SkillsLocal = pgtype.Text{String: *req.SkillsLocal, Valid: true}
}
// thinking_level handling (MUL-2339). Tri-state semantics:
// - field omitted → leave column alone (COALESCE narg), but if a
@@ -501,3 +501,94 @@ func TestMentionAgent_RejectsCrossWorkspaceAgentUUID(t *testing.T) {
beforeCount, afterCount)
}
}
// TestShouldEnqueueOnComment_PrivateAgentGate is the regression test for
// GH #3300: after an owner/admin assigns a private agent to an issue, the
// agent's UUID is "welded" onto that issue and any member with comment
// access could previously dispatch a new task to the private agent simply by
// posting a plain (non-@mention) comment, bypassing the visibility gate that
// #2359 added to chat / @mention / assignment.
//
// The gate must:
// - reject plain workspace members (not owner, not admin, not agent owner)
// - allow the agent owner
// - allow workspace owners/admins
// - allow agent-to-agent traffic regardless of agent visibility
func TestShouldEnqueueOnComment_PrivateAgentGate(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, ownerID, memberID := privateAgentTestFixture(t)
// Assign the private agent to a fresh issue. Owner/admin would normally
// be the one performing this step; we insert directly so the test
// focuses on the on_comment trigger path.
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id,
assignee_type, assignee_id, number)
VALUES ($1, 'on_comment private-agent gate test', 'todo', 'medium', 'member', $2,
'agent', $3,
COALESCE((SELECT MAX(number) FROM issue WHERE workspace_id = $1), 0) + 1)
RETURNING id
`, testWorkspaceID, testUserID, agentID).Scan(&issueID); err != nil {
t.Fatalf("create issue assigned to private agent: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(issueID))
if err != nil {
t.Fatalf("load issue: %v", err)
}
cases := []struct {
name string
actorType string
actorID string
want bool
reason string
}{
{
name: "plain member — denied",
actorType: "member",
actorID: memberID,
want: false,
reason: "GH #3300: plain members must not be able to dispatch a task to a private agent via on_comment",
},
{
name: "agent owner — allowed",
actorType: "member",
actorID: ownerID,
want: true,
reason: "agent owner is always in the allowed_principals set",
},
{
name: "workspace owner — allowed",
actorType: "member",
actorID: testUserID,
want: true,
reason: "workspace owners/admins are in the allowed_principals set",
},
{
name: "agent-to-agent — allowed",
actorType: "agent",
actorID: agentID,
want: true,
reason: "A2A traffic bypasses the visibility gate by design",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := testHandler.shouldEnqueueOnComment(ctx, issue, tc.actorType, tc.actorID)
if got != tc.want {
t.Fatalf("%s\n actor=%s/%s got=%v want=%v",
tc.reason, tc.actorType, tc.actorID, got, tc.want)
}
})
}
}
@@ -1,182 +0,0 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestCreateAgent_SkillsLocal_Validation pins the contract for the
// per-agent host-skill merge switch added with MUL-2603 / GitHub #3052:
//
// - Field omitted → platform default "merge" is persisted (preserves the
// pre-MUL-2603 inherit-from-machine behavior so existing personal
// workflows that rely on locally installed skills keep working).
// - "ignore" / "merge" → accepted and round-tripped.
// - Any other value → 400 BadRequest. The DB CHECK would catch it as a
// 500 fallback, but we want a clean error for clients.
func TestCreateAgent_SkillsLocal_Validation(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
claudeRuntimeID := createClaudeProviderRuntime(t)
t.Cleanup(func() {
testPool.Exec(ctx,
`DELETE FROM agent WHERE workspace_id = $1 AND name LIKE 'skills-local-test-%'`,
testWorkspaceID,
)
})
t.Run("omitted defaults to merge", func(t *testing.T) {
body := map[string]any{
"name": "skills-local-test-default",
"runtime_id": claudeRuntimeID,
"visibility": "private",
"max_concurrent_tasks": 1,
}
w := httptest.NewRecorder()
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", body))
if w.Code != http.StatusCreated {
t.Fatalf("default skills_local: expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp["skills_local"] != "merge" {
t.Errorf("expected skills_local=merge (platform default), got %v", resp["skills_local"])
}
})
t.Run("explicit ignore round-trips", func(t *testing.T) {
body := map[string]any{
"name": "skills-local-test-ignore",
"runtime_id": claudeRuntimeID,
"visibility": "private",
"max_concurrent_tasks": 1,
"skills_local": "ignore",
}
w := httptest.NewRecorder()
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", body))
if w.Code != http.StatusCreated {
t.Fatalf("skills_local=ignore: expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp["skills_local"] != "ignore" {
t.Errorf("expected skills_local=ignore, got %v", resp["skills_local"])
}
})
t.Run("garbage value rejected with 400", func(t *testing.T) {
body := map[string]any{
"name": "skills-local-test-garbage",
"runtime_id": claudeRuntimeID,
"visibility": "private",
"max_concurrent_tasks": 1,
"skills_local": "yes-please",
}
w := httptest.NewRecorder()
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", body))
if w.Code != http.StatusBadRequest {
t.Fatalf("garbage skills_local: expected 400, got %d: %s", w.Code, w.Body.String())
}
})
}
// TestUpdateAgent_SkillsLocal_Tristate covers PATCH semantics: nil
// pointer means "no change", non-nil overrides after validation. Unlike
// thinking_level there is no "clear" mode — the column is NOT NULL.
func TestUpdateAgent_SkillsLocal_Tristate(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
claudeRuntimeID := createClaudeProviderRuntime(t)
agentID := createAgentOnRuntime(t, "skills-local-update-test", claudeRuntimeID, "")
// Demote the seed row to "ignore" so we can verify both omit (preserve
// ignore) and explicit-flip-back (set merge) paths.
if _, err := testPool.Exec(ctx, `UPDATE agent SET skills_local = 'ignore' WHERE id = $1`, agentID); err != nil {
t.Fatalf("seed skills_local=ignore: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent WHERE id = $1`, agentID)
})
t.Run("omitted preserves existing value", func(t *testing.T) {
body := map[string]any{
"name": "skills-local-update-test-renamed",
}
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPatch, "/api/agents/"+agentID, body), "id", agentID)
testHandler.UpdateAgent(w, req)
if w.Code != http.StatusOK {
t.Fatalf("name-only update: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp["skills_local"] != "ignore" {
t.Errorf("name-only update silently changed skills_local: got %v, want ignore", resp["skills_local"])
}
})
t.Run("explicit merge flips back", func(t *testing.T) {
body := map[string]any{
"skills_local": "merge",
}
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPatch, "/api/agents/"+agentID, body), "id", agentID)
testHandler.UpdateAgent(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update skills_local=merge: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(w.Body).Decode(&resp)
if resp["skills_local"] != "merge" {
t.Errorf("expected skills_local=merge after update, got %v", resp["skills_local"])
}
})
t.Run("garbage value is 400 not silent default", func(t *testing.T) {
body := map[string]any{
"skills_local": "all-of-them",
}
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPatch, "/api/agents/"+agentID, body), "id", agentID)
testHandler.UpdateAgent(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("garbage skills_local: expected 400, got %d: %s", w.Code, w.Body.String())
}
})
}
// TestNormalizeSkillsLocal_DriftStaysSafe is a unit-level safety net for
// the on-read coercion: if a row gets written with anything other than
// the two recognised values (hand edits, future schema drift, older
// daemons sending up garbage), the API must surface "merge" — the
// platform default — never leak an indeterminate value to clients or the
// daemon. Only the exact literal "ignore" maps to ignore; anything else
// coerces to the documented default.
func TestNormalizeSkillsLocal_DriftStaysSafe(t *testing.T) {
cases := []struct {
stored string
want string
}{
{"", "merge"},
{"ignore", "ignore"},
{"merge", "merge"},
{"IGNORE", "merge"}, // case-sensitive — drift falls back to platform default
{"yes", "merge"},
{"true", "merge"},
}
for _, c := range cases {
if got := normalizeSkillsLocal(c.stored); got != c.want {
t.Errorf("normalizeSkillsLocal(%q) = %q, want %q", c.stored, got, c.want)
}
}
}
@@ -446,10 +446,6 @@ func (h *Handler) CreateAgentFromTemplate(w http.ResponseWriter, r *http.Request
CustomArgs: ca,
McpConfig: nil,
Model: pgtype.Text{String: req.Model, Valid: req.Model != ""},
// skills_local has a NOT NULL CHECK ('ignore' | 'merge'); the Go zero
// value "" would fail the constraint instead of falling back to the
// column default. Match the public CreateAgent default explicitly.
SkillsLocal: "merge",
})
if err != nil {
// Mirror handler/agent.go:CreateAgent: when the duplicate is the
+1 -1
View File
@@ -731,7 +731,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
// the user is talking to someone else, not requesting work from the assignee.
// Also skip when replying in a member-started thread without mentioning the
// assignee — the user is continuing a member-to-member conversation.
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue, authorType, authorID) &&
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
// Always use the current comment as the trigger so the agent reads
-1
View File
@@ -1124,7 +1124,6 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
McpConfig: mcpConfig,
Model: agent.Model.String,
ThinkingLevel: agent.ThinkingLevel.String,
SkillsLocal: normalizeSkillsLocal(agent.SkillsLocal),
}
}
+122 -48
View File
@@ -88,8 +88,8 @@ type GitHubPullRequestResponse struct {
}
type GitHubConnectResponse struct {
URL string `json:"url"`
Configured bool `json:"configured"`
URL string `json:"url"`
Configured bool `json:"configured"`
}
func githubInstallationToResponse(i db.GithubInstallation) GitHubInstallationResponse {
@@ -489,6 +489,19 @@ func (h *Handler) ListPullRequestsForIssue(w http.ResponseWriter, r *http.Reques
// version numbers like "v1.2-3".
var identifierRe = regexp.MustCompile(`(?i)\b([a-z][a-z0-9]{1,9})-(\d+)\b`)
// closingIdentifierRe extracts identifiers that appear immediately after a
// GitHub-style closing keyword ("close[sd]?", "fix(e[sd])?", "resolve[sd]?"),
// optionally separated by a colon and whitespace. Matching is intentionally
// strict on adjacency — "Fix MUL-1" closes MUL-1, but "Fix login MUL-1"
// does not. This mirrors GitHub's own closing-keyword grammar and is the
// gate the webhook uses to decide whether to auto-advance an issue to
// `done` after a PR merges. References like "Follow up in MUL-2" and bare
// title prefixes like "MUL-1: ..." link the PR (via identifierRe) but
// never auto-close.
var closingIdentifierRe = regexp.MustCompile(
`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[:\s]+([a-z][a-z0-9]{1,9})-(\d+)\b`,
)
// HandleGitHubWebhook (POST /api/webhooks/github) is GitHub's destination for
// every event from a connected installation. We verify HMAC signature, route
// on X-GitHub-Event, and either upsert PR rows + auto-link to issues or
@@ -635,7 +648,7 @@ type ghPullRequestPayload struct {
AvatarURL string `json:"avatar_url"`
} `json:"user"`
} `json:"pull_request"`
Changes *ghPRChanges `json:"changes"`
Changes *ghPRChanges `json:"changes"`
Repository struct {
Name string `json:"name"`
Owner struct {
@@ -669,27 +682,27 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
state := derivePRState(p.PullRequest.State, p.PullRequest.Draft, p.PullRequest.Merged)
mergeable, clearMergeable := derivePRMergeableState(p.Action, p.PullRequest.MergeableState, baseRefChanged(p.Changes))
pr, err := h.Queries.UpsertGitHubPullRequest(ctx, db.UpsertGitHubPullRequestParams{
WorkspaceID: inst.WorkspaceID,
InstallationID: inst.InstallationID,
RepoOwner: p.Repository.Owner.Login,
RepoName: p.Repository.Name,
PrNumber: p.PullRequest.Number,
Title: p.PullRequest.Title,
State: state,
HtmlUrl: p.PullRequest.HTMLURL,
Branch: ptrToText(strPtrOrNil(p.PullRequest.Head.Ref)),
AuthorLogin: ptrToText(strPtrOrNil(p.PullRequest.User.Login)),
AuthorAvatarUrl: ptrToText(strPtrOrNil(p.PullRequest.User.AvatarURL)),
MergedAt: parseGHTime(p.PullRequest.MergedAt),
ClosedAt: parseGHTime(p.PullRequest.ClosedAt),
PrCreatedAt: parseGHTimeRequired(p.PullRequest.CreatedAt),
PrUpdatedAt: parseGHTimeRequired(p.PullRequest.UpdatedAt),
HeadSha: p.PullRequest.Head.SHA,
MergeableState: mergeable,
ClearMergeableState: pgtype.Bool{Bool: clearMergeable, Valid: true},
Additions: p.PullRequest.Additions,
Deletions: p.PullRequest.Deletions,
ChangedFiles: p.PullRequest.ChangedFiles,
WorkspaceID: inst.WorkspaceID,
InstallationID: inst.InstallationID,
RepoOwner: p.Repository.Owner.Login,
RepoName: p.Repository.Name,
PrNumber: p.PullRequest.Number,
Title: p.PullRequest.Title,
State: state,
HtmlUrl: p.PullRequest.HTMLURL,
Branch: ptrToText(strPtrOrNil(p.PullRequest.Head.Ref)),
AuthorLogin: ptrToText(strPtrOrNil(p.PullRequest.User.Login)),
AuthorAvatarUrl: ptrToText(strPtrOrNil(p.PullRequest.User.AvatarURL)),
MergedAt: parseGHTime(p.PullRequest.MergedAt),
ClosedAt: parseGHTime(p.PullRequest.ClosedAt),
PrCreatedAt: parseGHTimeRequired(p.PullRequest.CreatedAt),
PrUpdatedAt: parseGHTimeRequired(p.PullRequest.UpdatedAt),
HeadSha: p.PullRequest.Head.SHA,
MergeableState: mergeable,
ClearMergeableState: pgtype.Bool{Bool: clearMergeable, Valid: true},
Additions: p.PullRequest.Additions,
Deletions: p.PullRequest.Deletions,
ChangedFiles: p.PullRequest.ChangedFiles,
})
if err != nil {
slog.Warn("github: upsert pr failed", "err", err)
@@ -701,7 +714,8 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
// Auto-link: scan title/body/branch for issue identifiers, look them
// up in this workspace, attach the link rows. Idempotent (ON CONFLICT
// DO NOTHING) so re-firing the webhook doesn't duplicate.
// upserts the close_intent flag — see LinkIssueToPullRequest) so
// re-firing the webhook doesn't duplicate.
//
// RFC MUL-2414 §4.8: the PR mirror upsert above always runs (so re-enabling
// GitHub features restores history without backfill), but the link rows
@@ -711,43 +725,80 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
linkedIssueIDs := make([]string, 0)
if h.workspaceAutoLinkPRsEnabled(ctx, inst.WorkspaceID) {
idents := extractIdentifiers(p.PullRequest.Title, p.PullRequest.Body, p.PullRequest.Head.Ref)
// closingIdents is the subset of identifiers that this PR explicitly
// declared via a closing keyword ("Closes/Fixes/Resolves MUL-X").
// Linking still happens for every mention (idents above), but the
// link row's close_intent column — and therefore whether the
// auto-advance gate eventually fires — is only set for keyword-
// declared identifiers. Bare title prefixes and branch-name
// references are link-only.
closingIdents := map[string]struct{}{}
for _, c := range extractClosingIdentifiers(p.PullRequest.Title, p.PullRequest.Body) {
closingIdents[c] = struct{}{}
}
// close_intent should follow the PR title/body while the PR is still
// editable before its terminal close event. Once GitHub has delivered
// a terminal event, later edit/synchronize webhooks must not rewrite
// the merge-time close decision.
preserveCloseIntent := p.Action != "closed" && (state == "merged" || state == "closed")
prefix := h.getIssuePrefix(ctx, inst.WorkspaceID)
// reevalIssues collects each issue whose link row we just touched so
// we can re-run the auto-advance gate against the persisted aggregate
// after every link upsert in this event. Driving the gate off
// persisted state (instead of "did *this* webhook declare closing
// intent?") is what fixes the multi-PR sibling case: a PR with
// `Closes MUL-1` merges first while a link-only sibling is still
// open, then the sibling closes later — its webhook has no closing
// keyword, but the earlier link row carries close_intent=true, so
// MUL-1 still advances.
reevalIssues := make([]db.Issue, 0, len(idents))
for _, id := range idents {
issue, ok := h.lookupIssueByIdentifier(ctx, inst.WorkspaceID, prefix, id)
if !ok {
continue
}
_, declared := closingIdents[id]
closeIntent := declared && !preserveCloseIntent
if err := h.Queries.LinkIssueToPullRequest(ctx, db.LinkIssueToPullRequestParams{
IssueID: issue.ID,
PullRequestID: pr.ID,
LinkedByType: strToText("system"),
LinkedByID: pgtype.UUID{},
IssueID: issue.ID,
PullRequestID: pr.ID,
CloseIntent: closeIntent,
PreserveCloseIntent: preserveCloseIntent,
LinkedByType: strToText("system"),
LinkedByID: pgtype.UUID{},
}); err != nil {
slog.Warn("github: link failed", "err", err)
continue
}
linkedIssueIDs = append(linkedIssueIDs, uuidToString(issue.ID))
reevalIssues = append(reevalIssues, issue)
}
// A terminal PR event (`merged` or `closed`) may be the moment the
// last in-flight sibling resolves, so we re-evaluate the issue on
// both. We advance the issue to done when:
// 1. the issue isn't already terminal (`done` / `cancelled`);
// 2. no sibling PR is still `open` / `draft`;
// 3. at least one linked PR (this one or a sibling) is `merged`.
// Rule (3) prevents an "all closed-without-merge" sequence from
// silently auto-closing the issue — if nothing was ever delivered,
// the user should decide what to do manually.
if (state == "merged" || state == "closed") && issue.Status != "done" && issue.Status != "cancelled" {
counts, err := h.Queries.GetSiblingPullRequestStateCountsForIssue(ctx, db.GetSiblingPullRequestStateCountsForIssueParams{
IssueID: issue.ID,
ID: pr.ID,
})
if err != nil {
slog.Warn("github: count sibling pr states failed", "err", err, "issue_id", uuidToString(issue.ID))
// A terminal PR event (`merged` or `closed`) may be the moment the
// last in-flight sibling resolves. We re-evaluate every issue we
// just linked once both the PR row and the link row are persisted,
// so the aggregate query sees the freshest state. We advance the
// issue to done when:
// 1. the issue isn't already terminal (`done` / `cancelled`);
// 2. no linked PR is still `open` / `draft`;
// 3. at least one merged linked PR declared close_intent (a
// "Closes/Fixes/Resolves" keyword on its link row).
// Rule (3) is what prevents "Follow up in MUL-2" / "Unblocks MUL-3"
// references from being treated the same as "Closes MUL-1", and
// also prevents an "all closed-without-merge" sequence from
// silently auto-closing the issue — if nothing carrying closing
// intent was ever delivered, the user should decide manually.
if state == "merged" || state == "closed" {
for _, issue := range reevalIssues {
if issue.Status == "done" || issue.Status == "cancelled" {
continue
}
anyMerged := state == "merged" || counts.MergedCount > 0
if counts.OpenCount == 0 && anyMerged {
counts, err := h.Queries.GetIssuePullRequestCloseAggregate(ctx, issue.ID)
if err != nil {
slog.Warn("github: count linked pr states failed", "err", err, "issue_id", uuidToString(issue.ID))
continue
}
if counts.OpenCount == 0 && counts.MergedWithCloseIntentCount > 0 {
h.advanceIssueToDone(ctx, issue, workspaceID)
}
}
@@ -757,7 +808,7 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
// Broadcast PR change to the workspace so any open issue detail page
// re-queries its PR list.
h.publish(protocol.EventPullRequestUpdated, workspaceID, "system", "", map[string]any{
"pull_request": resp,
"pull_request": resp,
"linked_issue_ids": linkedIssueIDs,
})
}
@@ -990,6 +1041,29 @@ func extractIdentifiers(parts ...string) []string {
return out
}
// extractClosingIdentifiers pulls every "PREFIX-NUMBER" identifier that
// appears immediately after a GitHub-style closing keyword in the supplied
// fields, deduplicating in input order. Identifiers in branch names are
// intentionally excluded — callers should pass only title and body — because
// branch names are not natural-language fields and treating "mul-1/fix-login"
// as a close declaration would silently re-open the bug this gate is meant
// to fix.
func extractClosingIdentifiers(parts ...string) []string {
seen := map[string]struct{}{}
out := []string{}
for _, src := range parts {
for _, m := range closingIdentifierRe.FindAllStringSubmatch(src, -1) {
ident := strings.ToUpper(m[1]) + "-" + m[2]
if _, dup := seen[ident]; dup {
continue
}
seen[ident] = struct{}{}
out = append(out, ident)
}
}
return out
}
// lookupIssueByIdentifier looks up an issue in the given workspace by its
// "PREFIX-NUMBER" identifier. Returns the row + true if the prefix matches
// workspaceAutoLinkPRsEnabled reports whether the workspace allows the
+569 -3
View File
@@ -68,6 +68,72 @@ func TestExtractIdentifiers(t *testing.T) {
}
}
func TestExtractClosingIdentifiers(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{
name: "single_closes",
in: []string{"", "Closes MUL-1"},
want: []string{"MUL-1"},
},
{
name: "all_keyword_inflections",
in: []string{
"",
"close MUL-1\nclosed MUL-2\ncloses MUL-3\nfix MUL-4\nfixes MUL-5\nfixed MUL-6\nresolve MUL-7\nresolves MUL-8\nresolved MUL-9",
},
want: []string{"MUL-1", "MUL-2", "MUL-3", "MUL-4", "MUL-5", "MUL-6", "MUL-7", "MUL-8", "MUL-9"},
},
{
name: "case_insensitive_and_colon",
in: []string{"CLOSES: MUL-1", "Fixes:MUL-2 resolves MUL-3"},
want: []string{"MUL-1", "MUL-2", "MUL-3"},
},
{
name: "bare_reference_does_not_close",
// The bug-report repro: only ABC-1 carries closing intent.
// ABC-2/ABC-3 are linked (extractIdentifiers) but must not
// appear in the closing set.
in: []string{"ABC-1: Lorem Ipsum", "Closes ABC-1. Follow up work planned in ABC-2. Unblocks ABC-3."},
want: []string{"ABC-1"},
},
{
name: "keyword_not_adjacent_does_not_close",
// "Fix login MUL-1" — keyword present but the identifier is
// not adjacent. Consistent with GitHub's closing-keyword
// grammar; matches via extractIdentifiers for linking only.
in: []string{"Fix login MUL-1", ""},
want: []string{},
},
{
name: "dedupe_across_fields",
in: []string{"Closes MUL-1", "fixes mul-1"},
want: []string{"MUL-1"},
},
{
name: "no_match_on_disclosed_or_foreclose",
// Word-boundary guards against keyword fragments embedded
// in larger words ("Disclosed MUL-1", "Foreclose MUL-1").
in: []string{"Disclosed MUL-1 in foreclose MUL-2", ""},
want: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractClosingIdentifiers(tc.in...)
if len(got) == 0 && len(tc.want) == 0 {
return
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("extractClosingIdentifiers() = %v, want %v", got, tc.want)
}
})
}
}
func TestDerivePRState(t *testing.T) {
cases := []struct {
state string
@@ -205,7 +271,7 @@ func TestWebhook_MergedPR_AdvancesLinkedIssueToDone(t *testing.T) {
"number": 1234,
"html_url": "https://github.com/acme/widget/pull/1234",
"title": "Fix login " + created.Identifier,
"body": "",
"body": "Closes " + created.Identifier,
"state": "closed",
"draft": false,
"merged": true,
@@ -676,6 +742,506 @@ func TestWebhook_AllClosedWithoutMerge(t *testing.T) {
}
}
// fireBareWebhook is a focused helper for the closing-keyword gate tests
// below: it fires a single merged-PR webhook with caller-controlled title,
// body, and branch so each test can exercise a specific PR-grammar shape
// (bare identifier, mixed closing/non-closing references, branch-only
// reference) without re-typing the full webhook envelope each time.
func fireBareWebhook(t *testing.T, secret string, installationID int64, prNumber int32, title, body, branch string) {
t.Helper()
payload := map[string]any{
"action": "closed",
"pull_request": map[string]any{
"number": prNumber,
"html_url": fmt.Sprintf("https://github.com/acme/widget/pull/%d", prNumber),
"title": title,
"body": body,
"state": "closed",
"draft": false,
"merged": true,
"merged_at": "2026-04-29T00:00:00Z",
"closed_at": "2026-04-29T00:00:00Z",
"created_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-29T00:00:00Z",
"head": map[string]any{"ref": branch},
"user": map[string]any{"login": "octocat"},
},
"repository": map[string]any{"name": "widget", "owner": map[string]any{"login": "acme"}},
"installation": map[string]any{"id": installationID},
}
raw, _ := json.Marshal(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(raw)
sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(raw))
req.Header.Set("X-GitHub-Event", "pull_request")
req.Header.Set("X-Hub-Signature-256", sig)
testHandler.HandleGitHubWebhook(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("webhook pr=%d: expected 202, got %d (%s)", prNumber, rec.Code, rec.Body.String())
}
}
// TestWebhook_MergedPR_OnlyClosesIdentifiersWithClosingKeyword is the repro
// from GitHub issue multica-ai/multica#3264: a PR that mentions three issues
// must only auto-complete the one declared with a closing keyword. Follow-up
// / unblocks references are linked but stay in their previous status.
func TestWebhook_MergedPR_OnlyClosesIdentifiersWithClosingKeyword(t *testing.T) {
if testHandler == nil {
t.Skip("handler test fixture not initialized (no DB?)")
}
ctx := context.Background()
secret := "closing-keyword-secret"
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
// Three issues to mention in the same PR body.
createIssue := func(title string) IssueResponse {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "in_progress",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue %q: %d %s", title, w.Code, w.Body.String())
}
var out IssueResponse
json.NewDecoder(w.Body).Decode(&out)
return out
}
closes := createIssue("primary work")
followUp := createIssue("follow up work")
unblocks := createIssue("unblocked work")
t.Cleanup(func() {
for _, id := range []string{closes.ID, followUp.ID, unblocks.ID} {
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, id)
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, id)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id)
}
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
})
const installationID int64 = 30264001
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
WorkspaceID: parseUUID(testWorkspaceID),
InstallationID: installationID,
AccountLogin: "closing-keyword-acct",
AccountType: "User",
}); err != nil {
t.Fatalf("CreateGitHubInstallation: %v", err)
}
// PR title mirrors the reporter's repro shape — bare identifier prefix —
// and body declares closing intent on `closes` only.
title := closes.Identifier + ": Lorem Ipsum dolor sit amet"
body := fmt.Sprintf(
"Closes %s. Follow up work planned in %s. Unblocks %s.",
closes.Identifier, followUp.Identifier, unblocks.Identifier,
)
fireBareWebhook(t, secret, installationID, 1, title, body, "fix/login")
// All three should be linked — auto-link layer is intentionally generous.
for _, issue := range []IssueResponse{closes, followUp, unblocks} {
linked, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(issue.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue(%s): %v", issue.Identifier, err)
}
if len(linked) != 1 {
t.Errorf("expected %s to be linked to the PR, got %d link rows", issue.Identifier, len(linked))
}
}
// Only the closing-keyword identifier advances to done.
wantStatus := map[string]string{
closes.ID: "done",
followUp.ID: "in_progress",
unblocks.ID: "in_progress",
}
for _, issue := range []IssueResponse{closes, followUp, unblocks} {
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID))
if err != nil {
t.Fatalf("GetIssue(%s): %v", issue.Identifier, err)
}
if got.Status != wantStatus[issue.ID] {
t.Errorf("issue %s: status = %q, want %q", issue.Identifier, got.Status, wantStatus[issue.ID])
}
}
}
// TestWebhook_MergedPR_TitlePrefixDoesNotClose locks in the design choice
// that a bare "MUL-X: foo" title (no closing keyword) links but never
// auto-completes. The user must write `Closes MUL-X` somewhere if they want
// the merge to flip the status.
func TestWebhook_MergedPR_TitlePrefixDoesNotClose(t *testing.T) {
if testHandler == nil {
t.Skip("handler test fixture not initialized (no DB?)")
}
ctx := context.Background()
secret := "title-prefix-secret"
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "title-prefix repro",
"status": "in_progress",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
const installationID int64 = 30264002
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
WorkspaceID: parseUUID(testWorkspaceID),
InstallationID: installationID,
AccountLogin: "title-prefix-acct",
AccountType: "User",
}); err != nil {
t.Fatalf("CreateGitHubInstallation: %v", err)
}
fireBareWebhook(t, secret, installationID, 2, created.Identifier+": fix something", "", "fix/login")
linked, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue: %v", err)
}
if len(linked) != 1 {
t.Errorf("expected 1 linked PR even without a closing keyword, got %d", len(linked))
}
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue: %v", err)
}
if got.Status != "in_progress" {
t.Errorf("expected issue to stay in_progress (title prefix alone is not closing intent), got %q", got.Status)
}
}
// TestWebhook_MergedPR_BranchNameDoesNotClose guards the conservative design
// decision that identifiers extracted from the branch name link the PR but
// never auto-complete the issue — branch names are not natural-language
// fields and cannot carry a closing keyword.
func TestWebhook_MergedPR_BranchNameDoesNotClose(t *testing.T) {
if testHandler == nil {
t.Skip("handler test fixture not initialized (no DB?)")
}
ctx := context.Background()
secret := "branch-name-secret"
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "branch-name repro",
"status": "in_progress",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
const installationID int64 = 30264003
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
WorkspaceID: parseUUID(testWorkspaceID),
InstallationID: installationID,
AccountLogin: "branch-name-acct",
AccountType: "User",
}); err != nil {
t.Fatalf("CreateGitHubInstallation: %v", err)
}
branch := strings.ToLower(created.Identifier) + "/fix-login"
fireBareWebhook(t, secret, installationID, 3, "Fix login flow", "", branch)
linked, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue: %v", err)
}
if len(linked) != 1 {
t.Errorf("expected branch-name reference to still link the PR, got %d link rows", len(linked))
}
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue: %v", err)
}
if got.Status != "in_progress" {
t.Errorf("expected issue to stay in_progress (branch-name reference is not closing intent), got %q", got.Status)
}
}
// firePRWebhook fires a webhook for a single PR with caller-controlled
// title, body, branch, and lifecycle (open / merged / closed without
// merge). Tests below need the open→merged sequence so close_intent on
// one PR has to persist across multiple webhook events for a sibling PR.
func firePRWebhook(t *testing.T, secret string, installationID int64, prNumber int32, title, body, branch, lifecycle string) {
t.Helper()
var action, state string
var merged bool
var mergedAt, closedAt any
switch lifecycle {
case "opened":
action, state, merged = "opened", "open", false
mergedAt, closedAt = nil, nil
case "edited":
action, state, merged = "edited", "open", false
mergedAt, closedAt = nil, nil
case "merged":
action, state, merged = "closed", "closed", true
mergedAt, closedAt = "2026-04-29T00:00:00Z", "2026-04-29T00:00:00Z"
case "edited_merged":
action, state, merged = "edited", "closed", true
mergedAt, closedAt = "2026-04-29T00:00:00Z", "2026-04-29T00:00:00Z"
case "closed":
action, state, merged = "closed", "closed", false
mergedAt, closedAt = nil, "2026-04-29T00:00:00Z"
default:
t.Fatalf("firePRWebhook: unknown lifecycle %q", lifecycle)
}
payload := map[string]any{
"action": action,
"pull_request": map[string]any{
"number": prNumber,
"html_url": fmt.Sprintf("https://github.com/acme/widget/pull/%d", prNumber),
"title": title,
"body": body,
"state": state,
"draft": false,
"merged": merged,
"merged_at": mergedAt,
"closed_at": closedAt,
"created_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-29T00:00:00Z",
"head": map[string]any{"ref": branch},
"user": map[string]any{"login": "octocat"},
},
"repository": map[string]any{"name": "widget", "owner": map[string]any{"login": "acme"}},
"installation": map[string]any{"id": installationID},
}
raw, _ := json.Marshal(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(raw)
sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(raw))
req.Header.Set("X-GitHub-Event", "pull_request")
req.Header.Set("X-Hub-Signature-256", sig)
testHandler.HandleGitHubWebhook(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("webhook pr=%d (%s): expected 202, got %d (%s)", prNumber, lifecycle, rec.Code, rec.Body.String())
}
}
func TestWebhook_CloseKeywordRemovedBeforeMergeDoesNotClose(t *testing.T) {
if testHandler == nil {
t.Skip("handler test fixture not initialized (no DB?)")
}
ctx := context.Background()
secret := "close-intent-removal-secret"
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "close intent can be removed",
"status": "in_progress",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
const installationID int64 = 30264005
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
WorkspaceID: parseUUID(testWorkspaceID),
InstallationID: installationID,
AccountLogin: "close-intent-removal-acct",
AccountType: "User",
}); err != nil {
t.Fatalf("CreateGitHubInstallation: %v", err)
}
firePRWebhook(t, secret, installationID, 1, "Implement removal path", "Closes "+created.Identifier, "feat/remove-close-intent", "opened")
firePRWebhook(t, secret, installationID, 1, "Implement removal path", "Related "+created.Identifier, "feat/remove-close-intent", "edited")
firePRWebhook(t, secret, installationID, 1, "Implement removal path", "Related "+created.Identifier, "feat/remove-close-intent", "merged")
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue after merge: %v", err)
}
if got.Status != "in_progress" {
t.Fatalf("after closing keyword was removed before merge: status = %q, want in_progress", got.Status)
}
counts, err := testHandler.Queries.GetIssuePullRequestCloseAggregate(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssuePullRequestCloseAggregate: %v", err)
}
if counts.MergedWithCloseIntentCount != 0 {
t.Fatalf("merged_with_close_intent_count = %d, want 0", counts.MergedWithCloseIntentCount)
}
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "post merge close keyword is link only",
"status": "in_progress",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue second: %d %s", w.Code, w.Body.String())
}
var second IssueResponse
json.NewDecoder(w.Body).Decode(&second)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, second.ID)
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, second.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, second.ID)
})
// Adding a closing keyword after the merge must not rewrite the
// merge-time decision and retroactively close either an existing link
// or a newly mentioned issue.
firePRWebhook(t, secret, installationID, 1, "Implement removal path", "Closes "+created.Identifier+"\nCloses "+second.Identifier, "feat/remove-close-intent", "edited_merged")
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue after post-merge edit: %v", err)
}
if got.Status != "in_progress" {
t.Errorf("after adding closing keyword post-merge: status = %q, want in_progress", got.Status)
}
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(second.ID))
if err != nil {
t.Fatalf("GetIssue second after post-merge edit: %v", err)
}
if got.Status != "in_progress" {
t.Errorf("second issue after post-merge closing keyword: status = %q, want in_progress", got.Status)
}
}
// TestWebhook_LinkOnlySiblingMergeAfterCloseKeywordPR is the regression
// guard for the multi-PR sibling case Elon flagged on the first attempt
// of this fix. Scenario:
//
// 1. PR A declares closing intent (`Closes MUL-X`) and is opened.
// 2. PR B references the same issue (link-only — no closing keyword)
// and is opened.
// 3. PR A merges. The issue stays in_progress because PR B is open.
// 4. PR B merges later. PR B's webhook has no closing keyword, so the
// previous implementation skipped re-evaluating the issue and the
// issue stayed stuck in_progress forever.
//
// The persisted close_intent column on issue_pull_request fixes this:
// the aggregate sees PR A's merged+close_intent row regardless of which
// webhook drives the re-evaluation, so PR B's merge advances the issue.
func TestWebhook_LinkOnlySiblingMergeAfterCloseKeywordPR(t *testing.T) {
if testHandler == nil {
t.Skip("handler test fixture not initialized (no DB?)")
}
ctx := context.Background()
secret := "link-only-sibling-secret"
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "needs two prs",
"status": "in_progress",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
const installationID int64 = 30264004
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
WorkspaceID: parseUUID(testWorkspaceID),
InstallationID: installationID,
AccountLogin: "link-only-sibling-acct",
AccountType: "User",
}); err != nil {
t.Fatalf("CreateGitHubInstallation: %v", err)
}
// 1) PR A opens with closing intent.
firePRWebhook(t, secret, installationID, 1, "Implement primary path", "Closes "+created.Identifier, "feat/primary", "opened")
// 2) PR B opens link-only — title prefix mention, no closing keyword.
firePRWebhook(t, secret, installationID, 2, created.Identifier+": follow-up cleanup", "", "feat/cleanup", "opened")
// Sanity: issue is still in_progress (both PRs open).
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue after open: %v", err)
}
if got.Status != "in_progress" {
t.Fatalf("after both PRs opened: status = %q, want in_progress", got.Status)
}
// 3) PR A merges. PR B still open → issue stays in_progress.
firePRWebhook(t, secret, installationID, 1, "Implement primary path", "Closes "+created.Identifier, "feat/primary", "merged")
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue after A merge: %v", err)
}
if got.Status != "in_progress" {
t.Fatalf("after PR A merged with PR B still open: status = %q, want in_progress", got.Status)
}
// 4) PR B merges (link-only, no closing keyword). The persisted
// close_intent on PR A's link must still carry the advance.
firePRWebhook(t, secret, installationID, 2, created.Identifier+": follow-up cleanup", "", "feat/cleanup", "merged")
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue after B merge: %v", err)
}
if got.Status != "done" {
t.Errorf("after both PRs merged (A with close_intent, B link-only): status = %q, want done", got.Status)
}
}
// ── CI / mergeable_state tests ─────────────────────────────────────────────
func TestDerivePRMergeableState(t *testing.T) {
@@ -720,9 +1286,9 @@ func TestAggregateChecksConclusion(t *testing.T) {
return *p
}
cases := []struct {
name string
name string
failed, passed, pending, total int64
want string
want string
}{
{"no_suites_nil", 0, 0, 0, 0, "<nil>"},
{"any_failure_wins", 1, 5, 0, 6, "failed"},
+357 -7
View File
@@ -208,19 +208,43 @@ func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string
return agentID
}
// createHandlerTestTaskForAgent seeds a queued agent_task_queue row for the
// given agent and returns the task UUID. Used by tests that need to set
// X-Task-ID alongside X-Agent-ID — resolveActor now requires the pair to be
// present and consistent before granting "agent" actor identity.
// createHandlerTestTaskForAgent seeds a running agent_task_queue row for the
// given agent (with no associated issue) and returns the task UUID. Used by
// tests that need to set X-Task-ID alongside X-Agent-ID — resolveActor now
// requires the pair to be present and consistent before granting "agent"
// actor identity.
func createHandlerTestTaskForAgent(t *testing.T, agentID string) string {
return createHandlerTestTaskForAgentOnIssue(t, agentID, "")
}
// createHandlerTestTaskForAgentOnIssue seeds a running agent_task_queue row
// for the given agent, optionally bound to an issue (pass "" to leave
// issue_id NULL). The bound-issue form is needed by the self-loop guard
// test, which compares the calling task's issue_id against the promoted
// issue — only a same-issue match counts as a true self-loop.
//
// Status is 'running' because X-Task-ID is something a currently-executing
// task sends. Using 'running' also keeps the seed outside the
// idx_one_pending_task_per_issue_agent unique index (queued/dispatched only)
// and outside callers' `status='queued'` count assertions, so tests can
// assert that the handler did or did not enqueue a NEW task without
// double-counting the seed.
func createHandlerTestTaskForAgentOnIssue(t *testing.T, agentID, issueID string) string {
t.Helper()
var issueArg any
if issueID == "" {
issueArg = nil
} else {
issueArg = issueID
}
var taskID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority)
VALUES ($1, $2, 'queued', 0)
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, started_at)
VALUES ($1, $2, 'running', 0, $3, now())
RETURNING id
`, agentID, handlerTestRuntimeID(t)).Scan(&taskID); err != nil {
`, agentID, handlerTestRuntimeID(t), issueArg).Scan(&taskID); err != nil {
t.Fatalf("failed to create handler test task: %v", err)
}
t.Cleanup(func() {
@@ -2624,6 +2648,332 @@ func TestBacklogToTodoTriggersAgent(t *testing.T) {
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestBacklogToTodoByAgentTriggersDifferentAssignee verifies that the
// documented sub-task chain works: when an agent (parent / Step 1) promotes
// a backlog issue assigned to a different agent (child / Step 2), the
// child's task is enqueued. Previously the backlog→active trigger was
// gated on `actorType == "member"`, which silently dropped agent-driven
// promotions and broke the serial sub-task workflow.
func TestBacklogToTodoByAgentTriggersDifferentAssignee(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Parent agent (the actor) + child agent (the assignee).
parentAgent := createHandlerTestAgent(t, "Backlog Parent Agent", nil)
childAgent := createHandlerTestAgent(t, "Backlog Child Agent", nil)
parentTask := createHandlerTestTaskForAgent(t, parentAgent)
// Create a backlog issue assigned to the child agent — should NOT trigger
// on creation (backlog parking-lot rule).
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Serial sub-task Step 2",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": childAgent,
})
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)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Parent agent promotes backlog → todo on behalf of the X-Task it is
// currently running. Must enqueue exactly one task for the child agent.
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", created.ID)
req.Header.Set("X-Agent-ID", parentAgent)
req.Header.Set("X-Task-ID", parentTask)
testHandler.UpdateIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var childTasks int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, childAgent,
).Scan(&childTasks); err != nil {
t.Fatalf("failed to count child tasks: %v", err)
}
if childTasks != 1 {
t.Fatalf("expected exactly 1 task enqueued for child agent after agent-driven backlog→todo, got %d", childTasks)
}
}
// TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger verifies the
// task-issue-scoped self-loop guard: an agent whose CURRENT task is
// running on issue I and who flips I from backlog to an active status
// must NOT enqueue itself for I again. Without this guard the agent
// would re-trigger every cycle it completed on I and immediately
// re-enter the same path.
//
// This is the true self-loop case (calling task is on the SAME issue
// being promoted). The complementary case — same agent, DIFFERENT
// issue — is the documented serial chain and is covered by
// TestBacklogToTodoByAgentSameAgentDifferentIssue.
func TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
selfAgent := createHandlerTestAgent(t, "Backlog Self Agent", nil)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Self-promoted backlog",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": selfAgent,
})
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)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Task bound to the SAME issue being promoted — true self-loop.
selfTask := createHandlerTestTaskForAgentOnIssue(t, selfAgent, created.ID)
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", created.ID)
req.Header.Set("X-Agent-ID", selfAgent)
req.Header.Set("X-Task-ID", selfTask)
testHandler.UpdateIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var tasks int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, selfAgent,
).Scan(&tasks); err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
if tasks != 0 {
t.Fatalf("expected no self-trigger when agent promotes the same issue its task is running on, got %d queued tasks", tasks)
}
}
// TestBacklogToTodoByAgentSameAgentDifferentIssue verifies the documented
// same-agent serial chain still fires: when an agent is running a task on
// issue I1 and promotes a DIFFERENT backlog issue I2 (also assigned to
// itself), I2 must be enqueued. This was over-blocked by the previous
// agent-id-based self-loop guard, which made the same-agent serial
// workflow silently break.
func TestBacklogToTodoByAgentSameAgentDifferentIssue(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID := createHandlerTestAgent(t, "Backlog Same-Agent Chain", nil)
// Step 1 issue — the one the agent is currently working on.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Step 1 (running)",
"status": "in_progress",
"assignee_type": "agent",
"assignee_id": agentID,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue step1: expected 201, got %d: %s", w.Code, w.Body.String())
}
var step1 IssueResponse
json.NewDecoder(w.Body).Decode(&step1)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, step1.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, step1.ID)
})
// Step 2 issue — backlog, also assigned to the same agent.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Step 2 (backlog)",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": agentID,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue step2: expected 201, got %d: %s", w.Code, w.Body.String())
}
var step2 IssueResponse
json.NewDecoder(w.Body).Decode(&step2)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, step2.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, step2.ID)
})
// Task is running on step1 — promoting step2 is NOT a self-loop.
step1Task := createHandlerTestTaskForAgentOnIssue(t, agentID, step1.ID)
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/issues/"+step2.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", step2.ID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", step1Task)
testHandler.UpdateIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue step2: expected 200, got %d: %s", w.Code, w.Body.String())
}
var step2Tasks int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
step2.ID, agentID,
).Scan(&step2Tasks); err != nil {
t.Fatalf("failed to count step2 tasks: %v", err)
}
if step2Tasks != 1 {
t.Fatalf("expected exactly 1 task enqueued on step2 for same-agent serial chain, got %d", step2Tasks)
}
}
// TestBatchBacklogToTodoByAgentTriggersAssignee mirrors the single-update
// serial-chain test on the BatchUpdateIssues path. Earlier the
// member-only gate would silently drop agent-driven batch promotions; the
// task-issue self-loop guard must let cross-issue (same-agent) batch
// promotions through.
func TestBatchBacklogToTodoByAgentTriggersAssignee(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
parentAgent := createHandlerTestAgent(t, "Batch Parent Agent", nil)
childAgent := createHandlerTestAgent(t, "Batch Child Agent", nil)
parentTask := createHandlerTestTaskForAgent(t, parentAgent)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Batch backlog child",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": childAgent,
})
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)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Drive the batch endpoint with the same agent identity headers.
w = httptest.NewRecorder()
req = newRequest("PATCH", "/api/issues/batch?workspace_id="+testWorkspaceID, map[string]any{
"issue_ids": []string{created.ID},
"updates": map[string]any{"status": "todo"},
})
req.Header.Set("X-Agent-ID", parentAgent)
req.Header.Set("X-Task-ID", parentTask)
testHandler.BatchUpdateIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("BatchUpdateIssues: expected 200, got %d: %s", w.Code, w.Body.String())
}
var childTasks int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, childAgent,
).Scan(&childTasks); err != nil {
t.Fatalf("failed to count child tasks: %v", err)
}
if childTasks != 1 {
t.Fatalf("expected exactly 1 task enqueued for child agent after batch agent-driven backlog→todo, got %d", childTasks)
}
}
// TestBacklogToTodoByAgentTriggersSquadLeader covers the squad branch of
// the backlog→active trigger when the actor is an agent: the leader agent
// of a squad must wake when one of its squad-assigned backlog issues is
// promoted by another agent (or by the leader itself acting from a task
// on a different issue). The task-issue self-loop guard must allow this —
// only a true same-issue self-loop should be suppressed.
func TestBacklogToTodoByAgentTriggersSquadLeader(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
leaderAgent := createHandlerTestAgent(t, "Backlog Squad Leader", nil)
driverAgent := createHandlerTestAgent(t, "Backlog Squad Driver", nil)
driverTask := createHandlerTestTaskForAgent(t, driverAgent)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, $2, '', $3, $4)
RETURNING id
`, testWorkspaceID, "Backlog Trigger Squad", leaderAgent, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM squad WHERE id = $1`, squadID) })
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Squad backlog issue",
"status": "backlog",
"assignee_type": "squad",
"assignee_id": squadID,
})
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)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Driver agent (not the leader, task is on no specific issue) promotes
// the squad-assigned backlog issue. Squad leader must be enqueued.
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", created.ID)
req.Header.Set("X-Agent-ID", driverAgent)
req.Header.Set("X-Task-ID", driverTask)
testHandler.UpdateIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var leaderTasks int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, leaderAgent,
).Scan(&leaderTasks); err != nil {
t.Fatalf("failed to count leader tasks: %v", err)
}
if leaderTasks != 1 {
t.Fatalf("expected exactly 1 squad-leader task after agent-driven backlog→todo on squad issue, got %d", leaderTasks)
}
}
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/daemon/register", bytes.NewBufferString(`{
+277 -39
View File
@@ -833,39 +833,178 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
scheduledFilter = pgtype.Bool{Bool: true, Valid: true}
}
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: wsUUID,
Limit: int32(limit),
Offset: int32(offset),
Status: statusFilter,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
AssigneeIds: assigneeIdsFilter,
CreatorID: creatorFilter,
ProjectID: projectFilter,
InvolvesUserID: involvesUserFilter,
Scheduled: scheduledFilter,
MetadataFilter: metadataFilter,
})
// Parse sort and direction params for dynamic ORDER BY.
// Manual sort (position) is always ASC — direction is ignored because
// the user defines order through drag-and-drop, reversing it has no
// product meaning.
sortCol := "position"
if s := r.URL.Query().Get("sort"); s != "" {
switch s {
case "position", "title", "created_at", "start_date", "due_date":
sortCol = s
case "priority":
sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
default:
writeError(w, http.StatusBadRequest, "invalid sort value")
return
}
}
sortDir := "ASC"
if sortCol != "position" {
if d := r.URL.Query().Get("direction"); d != "" {
switch strings.ToLower(d) {
case "asc":
sortDir = "ASC"
case "desc":
sortDir = "DESC"
default:
writeError(w, http.StatusBadRequest, "invalid direction value")
return
}
}
}
// Build dynamic SQL — same approach as ListGroupedIssues.
where := []string{"i.workspace_id = $1"}
args := []any{wsUUID}
addArg := func(v any) string {
args = append(args, v)
return "$" + strconv.Itoa(len(args))
}
if statusFilter.Valid {
where = append(where, fmt.Sprintf("i.status = %s", addArg(statusFilter.String)))
}
if priorityFilter.Valid {
where = append(where, fmt.Sprintf("i.priority = %s", addArg(priorityFilter.String)))
}
if assigneeFilter.Valid {
where = append(where, fmt.Sprintf("i.assignee_id = %s::uuid", addArg(assigneeFilter)))
}
if len(assigneeIdsFilter) > 0 {
where = append(where, fmt.Sprintf("i.assignee_id = ANY(%s::uuid[])", addArg(assigneeIdsFilter)))
}
if creatorFilter.Valid {
where = append(where, fmt.Sprintf("i.creator_id = %s::uuid", addArg(creatorFilter)))
}
if projectFilter.Valid {
where = append(where, fmt.Sprintf("i.project_id = %s::uuid", addArg(projectFilter)))
}
if scheduledFilter.Valid {
where = append(where, "(i.start_date IS NOT NULL OR i.due_date IS NOT NULL)")
}
if metadataFilter != nil {
where = append(where, fmt.Sprintf("i.metadata @> %s::jsonb", addArg(string(metadataFilter))))
}
if involvesUserFilter.Valid {
ref := addArg(involvesUserFilter)
where = append(where, fmt.Sprintf(`(
(i.assignee_type = 'agent' AND i.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = $1
AND a.owner_id = %[1]s::uuid
))
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1
AND sm.member_type = 'member'
AND sm.member_id = %[1]s::uuid
UNION
SELECT s.id
FROM squad s
JOIN agent a ON a.id = s.leader_id
WHERE s.workspace_id = $1
AND a.workspace_id = $1
AND a.owner_id = %[1]s::uuid
UNION
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
JOIN agent a ON a.id = sm.member_id
WHERE s.workspace_id = $1
AND sm.member_type = 'agent'
AND a.workspace_id = $1
AND a.owner_id = %[1]s::uuid
))
)`, ref))
}
whereSql := strings.Join(where, " AND ")
// Build ORDER BY clause.
orderBy := sortCol
if !strings.HasPrefix(sortCol, "CASE") {
orderBy = "i." + sortCol
}
orderBy += " " + sortDir
if sortCol == "start_date" || sortCol == "due_date" {
orderBy += " NULLS LAST"
}
orderBy += ", i.created_at DESC"
offsetRef := addArg(int64(offset))
limitRef := addArg(int64(limit))
query := fmt.Sprintf(`SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
i.assignee_type, i.assignee_id, i.creator_type, i.creator_id,
i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.metadata
FROM issue i
WHERE %s
ORDER BY %s
LIMIT %s OFFSET %s`, whereSql, orderBy, limitRef, offsetRef)
rows, err := h.DB.Query(ctx, query, args...)
if err != nil {
slog.Warn("ListIssues query failed", "error", err)
writeError(w, http.StatusInternalServerError, "failed to list issues")
return
}
defer rows.Close()
var issues []db.ListIssuesRow
for rows.Next() {
var row db.ListIssuesRow
if err := rows.Scan(
&row.ID,
&row.WorkspaceID,
&row.Title,
&row.Description,
&row.Status,
&row.Priority,
&row.AssigneeType,
&row.AssigneeID,
&row.CreatorType,
&row.CreatorID,
&row.ParentIssueID,
&row.Position,
&row.StartDate,
&row.DueDate,
&row.CreatedAt,
&row.UpdatedAt,
&row.Number,
&row.ProjectID,
&row.Metadata,
); err != nil {
slog.Warn("ListIssues scan failed", "error", err)
writeError(w, http.StatusInternalServerError, "failed to list issues")
return
}
issues = append(issues, row)
}
if err := rows.Err(); err != nil {
slog.Warn("ListIssues rows failed", "error", err)
writeError(w, http.StatusInternalServerError, "failed to list issues")
return
}
// Get the true total count for pagination awareness.
total, err := h.Queries.CountIssues(ctx, db.CountIssuesParams{
WorkspaceID: wsUUID,
Status: statusFilter,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
AssigneeIds: assigneeIdsFilter,
CreatorID: creatorFilter,
ProjectID: projectFilter,
InvolvesUserID: involvesUserFilter,
Scheduled: scheduledFilter,
MetadataFilter: metadataFilter,
})
if err != nil {
countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM issue i WHERE %s`, whereSql)
// Count query uses the same args minus the OFFSET and LIMIT params (last two added).
countArgs := args[:len(args)-2]
var total int64
if err := h.DB.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
total = int64(len(issues))
}
@@ -1194,6 +1333,43 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) {
}
}
sortCol := "position"
if s := r.URL.Query().Get("sort"); s != "" {
switch s {
case "position", "title", "created_at", "start_date", "due_date":
sortCol = s
case "priority":
sortCol = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
default:
writeError(w, http.StatusBadRequest, "invalid sort value")
return
}
}
sortDir := "ASC"
if sortCol != "position" {
if d := r.URL.Query().Get("direction"); d != "" {
switch strings.ToLower(d) {
case "asc":
sortDir = "ASC"
case "desc":
sortDir = "DESC"
default:
writeError(w, http.StatusBadRequest, "invalid direction value")
return
}
}
}
intraGroupOrder := sortCol
if !strings.HasPrefix(sortCol, "CASE") {
intraGroupOrder = "i." + sortCol
}
intraGroupOrder += " " + sortDir
if sortCol == "start_date" || sortCol == "due_date" {
intraGroupOrder += " NULLS LAST"
}
intraGroupOrder += ", i.created_at DESC"
offsetRef := addArg(int64(offset))
limitRef := addArg(int64(limit))
query := fmt.Sprintf(`
@@ -1206,7 +1382,7 @@ WITH ranked AS (
COUNT(*) OVER (PARTITION BY i.assignee_type, i.assignee_id) AS group_total,
ROW_NUMBER() OVER (
PARTITION BY i.assignee_type, i.assignee_id
ORDER BY i.position ASC, i.created_at DESC
ORDER BY %s
) AS rn
FROM issue i
WHERE %s
@@ -1227,7 +1403,7 @@ ORDER BY
END,
assignee_type NULLS LAST,
assignee_id NULLS LAST,
rn`, strings.Join(where, " AND "), offsetRef, offsetRef, limitRef)
rn`, intraGroupOrder, strings.Join(where, " AND "), offsetRef, offsetRef, limitRef)
rows, err := h.DB.Query(ctx, query, args...)
if err != nil {
@@ -2239,11 +2415,19 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
}
// Trigger the assigned agent when a member moves an issue out of backlog.
// Backlog acts as a parking lot — moving to an active status signals the
// issue is ready for work.
if statusChanged && !assigneeChanged && actorType == "member" &&
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" {
// Trigger the assigned agent when an issue moves out of backlog. Backlog
// acts as a parking lot — moving to an active status signals the issue is
// ready for work. Agent actors are allowed here so the documented
// serial sub-task workflow works (parent agent finishes Step 1, then
// promotes Step 2 from backlog→todo, regardless of who Step 2 is
// assigned to). The only excluded case is the real self-loop: an agent
// promoting the same issue its current task is running on. Same-agent,
// cross-issue handoff (Agent A finishing one task and promoting another
// issue assigned to A) must still fire — that is the documented serial
// chain.
if statusChanged && !assigneeChanged &&
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" &&
!h.isAgentRunningOnIssue(r, actorType, issue) {
if h.isAgentAssigneeReady(r.Context(), issue) {
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
}
@@ -2356,8 +2540,21 @@ func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bo
// trigger the assigned agent. Fires for any status — comments are
// conversational and can happen at any stage, including after completion
// (e.g. follow-up questions on a done issue).
func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue) bool {
if !h.isAgentAssigneeReady(ctx, issue) {
//
// Mirrors the private-agent gate that enqueueMentionedAgentTasks applies on the
// @mention path: once an owner/admin assigns a private agent to an issue, the
// agent's UUID is "welded" onto the issue and remains visible to every member
// who can view it. Without this check any of those members could dispatch a new
// task to the private agent simply by commenting (#3300).
func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue, actorType, actorID string) bool {
if !issue.AssigneeType.Valid || issue.AssigneeType.String != "agent" || !issue.AssigneeID.Valid {
return false
}
agent, err := h.Queries.GetAgent(ctx, issue.AssigneeID)
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
return false
}
if !h.canAccessPrivateAgent(ctx, agent, actorType, actorID, uuidToString(issue.WorkspaceID)) {
return false
}
// Coalescing queue: allow enqueue when a task is running (so the agent
@@ -2373,6 +2570,43 @@ func (h *Handler) shouldEnqueueOnComment(ctx context.Context, issue db.Issue) bo
return true
}
// isAgentRunningOnIssue reports whether the calling agent's current task
// (identified by X-Task-ID) is running for the exact issue being promoted.
// That is the only true self-loop on backlog→active: the agent flipping
// the same issue its own task is executing for would immediately re-enqueue
// itself, complete the run, flip again, and so on.
//
// Same-agent cross-issue handoff (Agent A finishing a task on issue I1 then
// promoting issue I2 — even when I2 is also assigned to A) is NOT a loop
// and must fire; that is the documented serial sub-task chain. Member
// actors never match.
//
// X-Task-ID is guaranteed to be present and consistent when actorType is
// "agent": resolveActor demotes the actor to "member" otherwise (handler.go
// resolveActor). We still recheck defensively — a future caller could pass
// agent identity through a different path.
func (h *Handler) isAgentRunningOnIssue(r *http.Request, actorType string, issue db.Issue) bool {
if actorType != "agent" {
return false
}
taskIDStr := r.Header.Get("X-Task-ID")
if taskIDStr == "" {
return false
}
taskUUID, err := util.ParseUUID(taskIDStr)
if err != nil {
return false
}
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
if err != nil {
return false
}
if !task.IssueID.Valid {
return false
}
return uuidToString(task.IssueID) == uuidToString(issue.ID)
}
// isAgentAssigneeReady checks if an issue is assigned to an active agent
// with a valid runtime.
func (h *Handler) isAgentAssigneeReady(ctx context.Context, issue db.Issue) bool {
@@ -2667,9 +2901,13 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
}
// Trigger agent when moving out of backlog (batch).
if statusChanged && !assigneeChanged && actorType == "member" &&
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" {
// Trigger agent when moving out of backlog (batch). Mirrors the
// single-update path above — agent actors are allowed so serial
// sub-task chains work, and the same task-issue self-loop guard
// prevents an agent from re-triggering itself on the same issue.
if statusChanged && !assigneeChanged &&
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" &&
!h.isAgentRunningOnIssue(r, actorType, issue) {
if h.isAgentAssigneeReady(r.Context(), issue) {
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
}
+1 -1
View File
@@ -84,7 +84,7 @@ func (h *Handler) notifyParentOfChildDone(ctx context.Context, prev, issue db.Is
mentionPrefix := h.buildParentAssigneeMention(ctx, parent)
content := fmt.Sprintf(
"%sSub-issue [%s](mention://issue/%s) — \"%s\" — is done. Confirm whether to advance the next step on this parent (and promote any waiting `backlog` sub-issues).",
"%sSub-issue [%s](mention://issue/%s) — \"%s\" — is done. Before promoting any waiting `backlog` sub-issue, read each sibling's description and only promote items whose stated dependencies are already satisfied — do not rely on this parent's higher-level breakdown alone. If a sibling's description conflicts with that breakdown (e.g. it lists a prerequisite the parent treats as parallel), do NOT change its status — leave it `backlog` and post a comment to confirm first.",
mentionPrefix, identifier, childID, title,
)
@@ -227,10 +227,6 @@ func (h *Handler) BootstrapOnboardingRuntime(w http.ResponseWriter, r *http.Requ
CustomArgs: []byte("[]"),
McpConfig: nil,
Model: pgtype.Text{},
// skills_local has a NOT NULL CHECK ('ignore' | 'merge'); the Go
// zero value "" would trip the constraint rather than fall back to
// the column default. Match the public CreateAgent default.
SkillsLocal: "merge",
})
if err != nil {
slog.Warn("bootstrap onboarding (shim): create assistant failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", req.WorkspaceID)...)
+288 -3
View File
@@ -528,6 +528,13 @@ func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) {
}
// DeleteAgentRuntime deletes a runtime after permission and dependency checks.
//
// The strict variant: refuses with 409 + structured `runtime_has_active_agents`
// when any non-archived agent is still bound to the runtime, and returns the
// blocking agent list in the response body so the front-end can pivot to the
// cascade dialog without an extra round-trip. The cascade itself lives at
// POST /api/runtimes/:id/archive-agents-and-delete (ArchiveAgentsAndDeleteRuntime
// below) and runs the multi-write teardown inside a single transaction.
func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
@@ -555,13 +562,16 @@ func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) {
userID := uuidToString(member.UserID)
// Check if any active (non-archived) agents are bound to this runtime.
activeCount, err := h.Queries.CountActiveAgentsByRuntime(r.Context(), rt.ID)
// Surface them on the 409 so the dialog can render the cascade plan
// directly from this response — saves a second round-trip when the
// user clicked Delete from a stale list page.
activeAgents, err := h.Queries.ListActiveAgentsByRuntime(r.Context(), rt.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check runtime dependencies")
return
}
if activeCount > 0 {
writeError(w, http.StatusConflict, "cannot delete runtime: it has active agents bound to it. Archive or reassign the agents first.")
if len(activeAgents) > 0 {
writeJSON(w, http.StatusConflict, runtimeHasActiveAgentsResponse(activeAgents))
return
}
@@ -604,3 +614,278 @@ func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// runtimeHasActiveAgentsResponse builds the structured 409 body shared by
// DeleteAgentRuntime (light-mode block) and ArchiveAgentsAndDeleteRuntime
// (cascade-plan-changed). The shape is:
//
// {
// "error": "...",
// "code": "runtime_has_active_agents" | "runtime_delete_plan_changed",
// "active_agents": [AgentResponse, ...]
// }
//
// Front-end branches on `code`. The caller picks which code to send; this
// helper just normalises the agent serialisation and the error string.
func runtimeHasActiveAgentsResponse(agents []db.Agent) map[string]any {
resp := make([]AgentResponse, len(agents))
for i, a := range agents {
resp[i] = agentToResponse(a)
}
return map[string]any{
"error": "cannot delete runtime: it has active agents bound to it. Archive or reassign the agents first.",
"code": "runtime_has_active_agents",
"active_agents": resp,
}
}
// archiveAgentsAndDeleteRuntimeRequest is the wire shape for the cascade
// endpoint. expected_active_agent_ids is the snapshot the user just confirmed
// in the dialog — the server compares it to the live set inside the
// transaction and refuses with runtime_delete_plan_changed if anything moved
// between dialog open and confirm. That guarantees the user is approving the
// exact agent set that will be archived, even if a teammate adds or archives
// an agent in the same window.
type archiveAgentsAndDeleteRuntimeRequest struct {
ExpectedActiveAgentIDs []string `json:"expected_active_agent_ids"`
}
// ArchiveAgentsAndDeleteRuntime is the cascade entry point: archive every
// agent currently bound to the runtime, cancel their queued/running tasks,
// pause autopilots that target them, hard-delete the now-detached archived
// rows so the agent.runtime_id FK no longer pins the runtime, and finally
// delete the runtime row itself — all inside a single transaction so a
// partial failure never leaves a runtime half-torn-down.
//
// Transaction order follows the reference revoke flow in
// revokeAndRemoveMember (workspace_revoke.go) so the two cascade paths share
// the same race-safety properties: the dispatcher can't claim a task whose
// runtime is about to vanish, autopilots can't fire onto a dead assignee,
// and post-commit publish events emit the same task:cancelled →
// agent:archived → daemon:register fan-out.
//
// The expected_active_agent_ids check is the load-bearing piece for the UX:
// the front-end snapshots the agent list when the dialog opens and presents
// the user a checkbox confirmation; if a teammate adds or archives an agent
// while that dialog is open, this endpoint refuses with
// runtime_delete_plan_changed and the latest list, so the user never confirms
// a stale plan.
func (h *Handler) ArchiveAgentsAndDeleteRuntime(w http.ResponseWriter, r *http.Request) {
runtimeID := chi.URLParam(r, "runtimeId")
runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
if !ok {
return
}
var req archiveAgentsAndDeleteRuntimeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
expected, ok := parseExpectedActiveAgentIDs(req.ExpectedActiveAgentIDs)
if !ok {
writeError(w, http.StatusBadRequest, "expected_active_agent_ids must be a list of valid UUIDs")
return
}
rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
if err != nil {
writeError(w, http.StatusNotFound, "runtime not found")
return
}
wsID := uuidToString(rt.WorkspaceID)
member, ok := h.requireWorkspaceMember(w, r, wsID, "runtime not found")
if !ok {
return
}
if !canEditRuntime(member, rt) {
writeError(w, http.StatusForbidden, "you can only delete your own runtimes")
return
}
userID := uuidToString(member.UserID)
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
// Lock the runtime row first. PostgreSQL's FK validation on
// agent.runtime_id requires FOR KEY SHARE on the parent runtime row,
// which conflicts with FOR UPDATE — so any concurrent INSERT or
// UPDATE that would point a new/moved agent at this runtime now
// blocks until our tx finishes. This is the "兜底" lock that keeps
// new actives from appearing between our snapshot and our archive.
if _, err := qtx.LockAgentRuntime(r.Context(), rt.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to lock runtime")
return
}
// Re-list active agents inside the transaction, with FOR UPDATE on
// each row so a concurrent archive/move of one of those existing
// agents also blocks until we commit. Comparing against the expected
// set here closes the dialog-open / user-confirm race: even if a
// teammate creates or archives an agent on this runtime while the
// dialog was open, the user is approving exactly the set the server
// is about to archive.
currentActive, err := qtx.ListActiveAgentsByRuntimeForUpdate(r.Context(), rt.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enumerate active agents")
return
}
if !activeAgentSetMatches(currentActive, expected) {
// Refuse with the latest snapshot so the front-end can re-render
// the dialog and force a fresh user confirmation. Reuses the
// shared response helper but overrides the code to a planning
// signal so the dialog can distinguish "you opened from a stale
// page" from "the plan you confirmed just changed under you".
body := runtimeHasActiveAgentsResponse(currentActive)
body["code"] = "runtime_delete_plan_changed"
body["error"] = "the active agent set changed; please review and confirm again."
writeJSON(w, http.StatusConflict, body)
return
}
// Build the agent ID list once — it's the explicit allowlist for the
// archive UPDATE below and the runtime-or-agent task cancel further
// down. By keying the archive off this list (not off runtime_id) we
// guarantee that agents not in the user's confirmed set can never
// be silently archived, even if the row-level locks above somehow
// missed something. Defense in depth.
currentActiveIDs := make([]pgtype.UUID, len(currentActive))
for i, a := range currentActive {
currentActiveIDs[i] = a.ID
}
// 1. Archive every active agent on this runtime, narrowed to the
// user-confirmed expected_active_agent_ids set (which equals
// currentActive at this point). Returns the affected rows so the
// post-commit publish loop can fan out agent:archived per agent.
archivedAgents, err := qtx.ArchiveAgentsByIDs(r.Context(), db.ArchiveAgentsByIDsParams{
ArchivedBy: member.UserID,
AgentIds: currentActiveIDs,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive agents")
return
}
// 2. Cancel queued/dispatched/running tasks. Match by runtime_id AND
// by archived agent ids: agent.runtime_id can be reassigned without
// rewriting historical agent_task_queue rows, so an agent we just
// archived may still own tasks pinned to a different runtime — and
// ClaimAgentTask does not gate on agent.archived_at.
archivedIDs := make([]pgtype.UUID, len(archivedAgents))
for i, a := range archivedAgents {
archivedIDs[i] = a.ID
}
cancelledTasks, err := qtx.CancelAgentTasksByRuntimeOrAgent(r.Context(), db.CancelAgentTasksByRuntimeOrAgentParams{
RuntimeIds: []pgtype.UUID{rt.ID},
AgentIds: archivedIDs,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to cancel tasks")
return
}
// 3. Pause autopilots whose assignee is one of the archived agents.
// Snapshots the full archived set on this runtime — including any
// that were already archived before this call — because the
// DeleteArchivedAgentsByRuntime below will hard-delete the lot, and
// a paused autopilot is much louder in the UI than a silently-
// dangling assignee_id (see migration 096 for why the FK is gone).
allArchivedIDs, err := qtx.ListArchivedAgentIDsByRuntime(r.Context(), rt.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to enumerate archived agents")
return
}
if len(allArchivedIDs) > 0 {
if err := qtx.PauseAutopilotsByAgentAssignees(r.Context(), allArchivedIDs); err != nil {
writeError(w, http.StatusInternalServerError, "failed to pause autopilots")
return
}
}
// 4. Hard-delete the archived agents so the agent.runtime_id FK
// (ON DELETE RESTRICT) no longer keeps the runtime alive.
if err := qtx.DeleteArchivedAgentsByRuntime(r.Context(), rt.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clean up archived agents")
return
}
// 5. Finally delete the runtime row itself.
if err := qtx.DeleteAgentRuntime(r.Context(), rt.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete runtime")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit transaction")
return
}
// Post-commit fan-out — same ordering as publishRevocation so subscribers
// observe task:cancelled before agent:archived before the runtime list
// refresh, matching the order other revocation paths use.
if h.TaskService != nil && len(cancelledTasks) > 0 {
h.TaskService.BroadcastCancelledTasks(r.Context(), cancelledTasks)
}
for _, a := range archivedAgents {
h.publish(protocol.EventAgentArchived, wsID, "member", userID, map[string]any{
"agent": agentToResponse(a),
})
}
h.publish(protocol.EventDaemonRegister, wsID, "member", userID, map[string]any{
"action": "delete",
})
slog.Info("runtime deleted via cascade",
"runtime_id", uuidToString(rt.ID),
"deleted_by", userID,
"agents_archived", len(archivedAgents),
"tasks_cancelled", len(cancelledTasks),
)
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"agents_archived": len(archivedAgents),
"tasks_cancelled": len(cancelledTasks),
})
}
// parseExpectedActiveAgentIDs validates the cascade endpoint's
// expected_active_agent_ids list. nil / empty is allowed (an empty set is a
// valid plan: "I confirmed there are no active agents" — the cascade then
// just deletes the runtime without archiving anything). Returns ok=false on
// any malformed UUID so the handler responds 400 instead of silently
// matching a different set.
func parseExpectedActiveAgentIDs(raw []string) (map[string]struct{}, bool) {
out := make(map[string]struct{}, len(raw))
for _, s := range raw {
u, err := util.ParseUUID(s)
if err != nil || !u.Valid {
return nil, false
}
out[uuidToString(u)] = struct{}{}
}
return out, true
}
// activeAgentSetMatches reports whether the live set of active agents on the
// runtime matches the snapshot the front-end confirmed. Order-insensitive
// because the front-end may render in any order; size + membership is what
// matters for "did the plan change?".
func activeAgentSetMatches(current []db.Agent, expected map[string]struct{}) bool {
if len(current) != len(expected) {
return false
}
for _, a := range current {
if _, ok := expected[uuidToString(a.ID)]; !ok {
return false
}
}
return true
}
@@ -0,0 +1,305 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// parseExpectedActiveAgentIDs is the cascade endpoint's input validator.
// Empty list is a valid plan ("no active agents" — cascade just deletes the
// runtime); malformed UUIDs must surface as 400 so a bug in the front-end
// can't silently dilute the plan check.
func TestParseExpectedActiveAgentIDs(t *testing.T) {
t.Run("empty list returns empty set, ok", func(t *testing.T) {
got, ok := parseExpectedActiveAgentIDs(nil)
if !ok {
t.Fatalf("expected ok for nil input")
}
if len(got) != 0 {
t.Fatalf("expected empty set, got %d entries", len(got))
}
})
t.Run("valid uuids are accepted and deduplicated by set semantics", func(t *testing.T) {
ids := []string{
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
"11111111-1111-1111-1111-111111111111", // dup is intentional
}
got, ok := parseExpectedActiveAgentIDs(ids)
if !ok {
t.Fatalf("expected ok for valid uuid list")
}
if len(got) != 2 {
t.Fatalf("expected dedup set of 2, got %d", len(got))
}
for _, want := range []string{
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
} {
if _, ok := got[want]; !ok {
t.Fatalf("expected %s in set", want)
}
}
})
t.Run("any malformed entry fails the whole list", func(t *testing.T) {
ids := []string{
"11111111-1111-1111-1111-111111111111",
"not-a-uuid",
}
_, ok := parseExpectedActiveAgentIDs(ids)
if ok {
t.Fatal("expected !ok for list containing malformed uuid")
}
})
}
// activeAgentSetMatches drives the runtime_delete_plan_changed branch: it
// must report mismatch for any divergence — extra agent, missing agent, or
// substituted agent — and accept order-insensitive set equality.
func TestActiveAgentSetMatches(t *testing.T) {
mkAgent := func(id string) db.Agent {
u, err := uuidFromString(id)
if err != nil {
t.Fatalf("uuidFromString: %v", err)
}
return db.Agent{ID: u}
}
a1 := mkAgent("11111111-1111-1111-1111-111111111111")
a2 := mkAgent("22222222-2222-2222-2222-222222222222")
a3 := mkAgent("33333333-3333-3333-3333-333333333333")
t.Run("equal sets match regardless of order", func(t *testing.T) {
expected := map[string]struct{}{
"11111111-1111-1111-1111-111111111111": {},
"22222222-2222-2222-2222-222222222222": {},
}
if !activeAgentSetMatches([]db.Agent{a2, a1}, expected) {
t.Fatal("expected match for set-equal inputs")
}
})
t.Run("missing agent is a mismatch", func(t *testing.T) {
expected := map[string]struct{}{
"11111111-1111-1111-1111-111111111111": {},
"22222222-2222-2222-2222-222222222222": {},
}
if activeAgentSetMatches([]db.Agent{a1}, expected) {
t.Fatal("expected mismatch when an agent disappeared")
}
})
t.Run("extra agent is a mismatch", func(t *testing.T) {
expected := map[string]struct{}{
"11111111-1111-1111-1111-111111111111": {},
}
if activeAgentSetMatches([]db.Agent{a1, a2}, expected) {
t.Fatal("expected mismatch when a new agent appeared")
}
})
t.Run("substituted agent is a mismatch", func(t *testing.T) {
expected := map[string]struct{}{
"11111111-1111-1111-1111-111111111111": {},
"22222222-2222-2222-2222-222222222222": {},
}
if activeAgentSetMatches([]db.Agent{a1, a3}, expected) {
t.Fatal("expected mismatch when one agent was swapped for another")
}
})
t.Run("both empty matches", func(t *testing.T) {
if !activeAgentSetMatches(nil, map[string]struct{}{}) {
t.Fatal("expected empty/empty to match")
}
})
}
func uuidFromString(s string) (pgtype.UUID, error) {
var u pgtype.UUID
if err := u.Scan(s); err != nil {
return pgtype.UUID{}, err
}
return u, nil
}
// TestDeleteAgentRuntime_StructuredConflict covers the new 409 shape: the
// strict DELETE refuses with `runtime_has_active_agents` and the body carries
// the live active-agent list so the front-end can pivot to the cascade dialog
// without a second round-trip.
func TestDeleteAgentRuntime_StructuredConflict(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID := createCascadeFixtureRuntime(t, ctx, "Cascade 409 Runtime")
agentID := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade 409 Agent")
_ = agentID
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/runtimes/"+runtimeID, nil)
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.DeleteAgentRuntime(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
var body struct {
Error string `json:"error"`
Code string `json:"code"`
ActiveAgents []AgentResponse `json:"active_agents"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Code != "runtime_has_active_agents" {
t.Fatalf("expected code runtime_has_active_agents, got %q", body.Code)
}
if len(body.ActiveAgents) != 1 || body.ActiveAgents[0].ID != agentID {
t.Fatalf("expected one active agent %s, got %+v", agentID, body.ActiveAgents)
}
}
// TestArchiveAgentsAndDeleteRuntime_HappyPath exercises the cascade endpoint
// end-to-end: with the correct expected_active_agent_ids snapshot, it must
// archive the active agent, delete the runtime row, and respond 200 with the
// counts.
func TestArchiveAgentsAndDeleteRuntime_HappyPath(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID := createCascadeFixtureRuntime(t, ctx, "Cascade Happy Runtime")
agentID := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Happy Agent")
w := httptest.NewRecorder()
req := newRequest("POST", "/api/runtimes/"+runtimeID+"/archive-agents-and-delete",
map[string]any{"expected_active_agent_ids": []string{agentID}})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ArchiveAgentsAndDeleteRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Runtime row must be gone.
var rtRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 0 {
t.Fatalf("expected runtime row to be deleted, found %d", rtRows)
}
// Agent row must be gone too — DeleteArchivedAgentsByRuntime hard-deletes
// the archived rows so the agent.runtime_id FK no longer pins the runtime.
var agentRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent WHERE id = $1`, agentID).Scan(&agentRows); err != nil {
t.Fatalf("count agent rows: %v", err)
}
if agentRows != 0 {
t.Fatalf("expected archived agent to be hard-deleted with runtime, found %d", agentRows)
}
}
// TestArchiveAgentsAndDeleteRuntime_PlanChanged proves the dialog-confirm
// race guard: if the user's snapshot of active agents drifts from the live
// set (somebody added or archived an agent while the dialog was open), the
// cascade endpoint must refuse with 409 + runtime_delete_plan_changed and
// surface the new live snapshot so the dialog can re-prompt.
func TestArchiveAgentsAndDeleteRuntime_PlanChanged(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID := createCascadeFixtureRuntime(t, ctx, "Cascade Drift Runtime")
agent1 := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Drift Agent A")
agent2 := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Drift Agent B")
// User confirmed only agent1 — but the live set is {agent1, agent2}.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/runtimes/"+runtimeID+"/archive-agents-and-delete",
map[string]any{"expected_active_agent_ids": []string{agent1}})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ArchiveAgentsAndDeleteRuntime(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
var body struct {
Code string `json:"code"`
ActiveAgents []AgentResponse `json:"active_agents"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Code != "runtime_delete_plan_changed" {
t.Fatalf("expected code runtime_delete_plan_changed, got %q", body.Code)
}
if len(body.ActiveAgents) != 2 {
t.Fatalf("expected 2 active agents in fresh snapshot, got %d", len(body.ActiveAgents))
}
// Runtime must still exist — the plan-changed branch is non-destructive.
var rtRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 1 {
t.Fatalf("expected runtime to survive plan-changed refusal, count=%d", rtRows)
}
_ = agent2
}
// createCascadeFixtureRuntime creates a fresh runtime owned by testUserID
// inside testWorkspaceID and registers cleanup. Each cascade test uses its
// own runtime so the destructive paths don't trample the shared fixture.
func createCascadeFixtureRuntime(t *testing.T, ctx context.Context, name string) string {
t.Helper()
var runtimeID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, owner_id, last_seen_at
)
VALUES ($1, NULL, $2, 'cloud', 'cascade-test', 'online', $3, '{}'::jsonb, $4, now())
RETURNING id
`, testWorkspaceID, name, name+" device", testUserID).Scan(&runtimeID); err != nil {
t.Fatalf("insert cascade fixture runtime: %v", err)
}
t.Cleanup(func() {
// Best-effort cleanup. The cascade endpoint deletes the runtime;
// these statements only matter when the test failed before the
// cascade ran.
testPool.Exec(context.Background(), `DELETE FROM agent WHERE runtime_id = $1`, runtimeID)
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
})
return runtimeID
}
func createCascadeFixtureAgent(t *testing.T, ctx context.Context, runtimeID, name string) string {
t.Helper()
var agentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'private', 1, $4)
RETURNING id
`, testWorkspaceID, name, runtimeID, testUserID).Scan(&agentID); err != nil {
t.Fatalf("insert cascade fixture agent: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
})
return agentID
}
+6
View File
@@ -36,6 +36,12 @@ func TestRuntimeHandlersRejectMalformedRuntimeID(t *testing.T) {
path: "/api/runtimes/not-a-uuid",
handle: testHandler.DeleteAgentRuntime,
},
{
name: "archive-agents-and-delete",
method: "POST",
path: "/api/runtimes/not-a-uuid/archive-agents-and-delete",
handle: testHandler.ArchiveAgentsAndDeleteRuntime,
},
{
name: "models",
method: "POST",
@@ -1 +0,0 @@
ALTER TABLE agent DROP COLUMN skills_local;
@@ -1,12 +0,0 @@
-- Per-agent switch controlling whether the Claude runtime merges the host
-- machine's `~/.claude/skills/` into the agent's skill set. 'merge' (default)
-- preserves the pre-existing inherit-from-machine behavior so existing
-- workflows that rely on locally installed skills keep working. 'ignore'
-- isolates the runtime so a broken local skill on one operator's machine
-- cannot silently crash a shared agent (GitHub #3052 — Claude exits before
-- reading stdin, leaving the daemon with "broken pipe"). Workspace skills
-- (`{workDir}/.claude/skills/`) are always loaded — the toggle only governs
-- the user-global directory.
ALTER TABLE agent
ADD COLUMN skills_local TEXT NOT NULL DEFAULT 'merge'
CHECK (skills_local IN ('ignore', 'merge'));
@@ -0,0 +1,3 @@
-- Intentionally empty. The local skill toggle was reverted before release, so
-- rolling this cleanup back should only remove the schema_migrations row; it
-- should not recreate an unused column or invent values for it.
@@ -0,0 +1,4 @@
-- Clean up the unreleased per-agent local skill toggle. Fresh/prod databases
-- that never applied 108_agent_skills_local are unchanged, while dev/staging
-- databases that tested it converge back to the reverted schema.
ALTER TABLE agent DROP COLUMN IF EXISTS skills_local;
@@ -0,0 +1,2 @@
ALTER TABLE issue_pull_request
DROP COLUMN close_intent;
@@ -0,0 +1,7 @@
-- Persist whether a PR ↔ issue link was created with explicit closing intent
-- (a "Closes/Fixes/Resolves PREFIX-N" keyword in the PR title or body). The
-- webhook auto-advance gate consults this column so a link-only sibling PR
-- closing later still resolves an issue that an earlier closing-keyword PR
-- had blocked from advancing.
ALTER TABLE issue_pull_request
ADD COLUMN close_intent BOOLEAN NOT NULL DEFAULT FALSE;
-13
View File
@@ -44,19 +44,6 @@ type ExecOptions struct {
// field rather than fail (so MUL-2339 can grow runtime support
// incrementally without breaking unrelated agents).
ThinkingLevel string
// SkillsLocal opts the runtime into ("merge") or out of ("ignore") the
// host machine's user-global skill directory. Honoured by the Claude
// backend in this package — "merge" (platform default; anything other
// than "ignore") preserves the inherit-from-machine behavior so the
// CLI walks `~/.claude/` directly, and "ignore" redirects
// CLAUDE_CONFIG_DIR to an isolated per-task scratch dir so the CLI
// never reads `~/.claude/skills/` (used to harden shared agents
// against broken host skills — GitHub #3052). Codex enforces the same
// contract one layer up in the daemon: `execenv.hydrateCodexSkills`
// skips the `~/.codex/skills/` seed when this value is "ignore", so
// the per-task CODEX_HOME exposes only workspace skills. Other
// backends ignore this option today (no-op).
SkillsLocal string
}
// Session represents a running agent execution.
+2 -248
View File
@@ -10,7 +10,6 @@ import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
@@ -67,51 +66,7 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
// Skill isolation. The platform default is "merge" — the CLI walks
// `~/.claude/` directly, including its `skills/`, so existing personal
// workflows that rely on locally installed Claude skills keep working
// out of the box. The agent owner can opt into "ignore" when a shared
// agent must be hardened against a broken local skill on one operator's
// machine, which otherwise crashes the CLI before it reads stdin
// (GitHub #3052: silent "broken pipe" exits).
//
// In "ignore" mode we point CLAUDE_CONFIG_DIR at a per-run scratch dir
// that mirrors `~/.claude/` as symlinks — except for `skills/`, which
// is omitted so the CLI cannot discover the host user's
// `~/.claude/skills/`. Everything else under `~/.claude/` — including
// the Linux/Windows `.credentials.json` login token, `settings.json`,
// `agents/`, `commands/`, `plugins/`, etc. — is symlinked through so
// Claude authentication and global config keep working without an
// `ANTHROPIC_API_KEY`. Workspace skills (`{cwd}/.claude/skills/`) load
// from cwd regardless and are not affected by either mode.
isolateClaudeConfig := opts.SkillsLocal == "ignore"
var claudeConfigDir string
var claudeConfigCleanup func()
if isolateClaudeConfig {
// Resolve the *effective* Claude config source before we strip
// CLAUDE_CONFIG_DIR from the child env in buildClaudeEnv.
// Precedence: agent custom_env > parent process env > `~/.claude`.
// Mirroring `~/.claude` blindly when the operator has explicitly
// pointed Claude at a different config dir (e.g. a managed install)
// would copy the wrong credentials into the scratch dir and break
// auth — see the second Must Fix in MUL-2603 review.
hostConfigDir := resolveHostClaudeConfigDir(b.cfg.Env)
dir, cleanup, err := newIsolatedClaudeConfigDir(opts.Cwd, hostConfigDir, b.cfg.Logger)
if err != nil {
cancel()
return nil, fmt.Errorf("isolate claude config dir: %w", err)
}
claudeConfigDir = dir
claudeConfigCleanup = cleanup
}
defer func() {
if claudeConfigCleanup != nil {
claudeConfigCleanup()
}
}()
cmd.Env = buildClaudeEnv(b.cfg.Env, claudeConfigDir)
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -156,14 +111,10 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
}
closeStdin()
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model, "skills_local", opts.SkillsLocal, "claude_config_dir", claudeConfigDir)
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
// cmd.Start() succeeded — transfer temp file ownership to the goroutine.
mcpFileCleanup = nil
// Transfer isolated-config cleanup the same way: the goroutine outlives
// this function, so it owns removal of the scratch dir.
isolatedConfigCleanup := claudeConfigCleanup
claudeConfigCleanup = nil
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
@@ -175,9 +126,6 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
if mcpConfigPath != "" {
defer os.Remove(mcpConfigPath)
}
if isolatedConfigCleanup != nil {
defer isolatedConfigCleanup()
}
startTime := time.Now()
var output strings.Builder
@@ -619,31 +567,6 @@ func buildEnv(extra map[string]string) []string {
return mergeEnv(os.Environ(), extra)
}
// buildClaudeEnv builds the env slice for the claude subprocess. When
// claudeConfigDir is non-empty (isolated-skills mode), it overrides
// CLAUDE_CONFIG_DIR for the child so the CLI looks at the per-task
// scratch dir instead of `~/.claude/`. We also strip the parent's
// CLAUDE_CONFIG_DIR so a daemon-host env var cannot accidentally win
// the override race. Callers in "merge" mode pass "" and behaviour
// matches the pre-MUL-2603 buildEnv path.
func buildClaudeEnv(extra map[string]string, claudeConfigDir string) []string {
env := mergeEnv(os.Environ(), extra)
if claudeConfigDir == "" {
return env
}
// Drop any CLAUDE_CONFIG_DIR already in the slice (from os.Environ or
// from custom_env) before appending the isolated override so the last
// entry wins deterministically.
filtered := env[:0]
for _, entry := range env {
if strings.HasPrefix(entry, "CLAUDE_CONFIG_DIR=") {
continue
}
filtered = append(filtered, entry)
}
return append(filtered, "CLAUDE_CONFIG_DIR="+claudeConfigDir)
}
func mergeEnv(base []string, extra map[string]string) []string {
env := make([]string, 0, len(base)+len(extra))
for _, entry := range base {
@@ -665,175 +588,6 @@ func isFilteredChildEnvKey(key string) bool {
strings.HasPrefix(key, "CLAUDE_CODE_")
}
// resolveHostClaudeConfigDir picks the directory we mirror into the per-task
// scratch CLAUDE_CONFIG_DIR. Precedence:
//
// 1. Agent custom_env CLAUDE_CONFIG_DIR (operator pinned this agent at a
// specific install — e.g. a managed shared profile).
// 2. Parent process CLAUDE_CONFIG_DIR (daemon-host env var; some self-host
// deployments set this globally so `claude login` writes there instead
// of `~/.claude`).
// 3. `~/.claude/` (the documented default for Linux/Windows; macOS keeps
// credentials in Keychain so the dir still holds settings/agents/etc.).
//
// Returns "" when none of the above are usable (no env, no home dir). The
// caller treats that as "host has no Claude config to mirror" — the scratch
// dir stays empty and the CLI relies on `ANTHROPIC_API_KEY` auth.
func resolveHostClaudeConfigDir(extraEnv map[string]string) string {
if v, ok := extraEnv["CLAUDE_CONFIG_DIR"]; ok && v != "" {
return v
}
if v := os.Getenv("CLAUDE_CONFIG_DIR"); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".claude")
}
// newIsolatedClaudeConfigDir creates a per-run scratch directory used as
// CLAUDE_CONFIG_DIR when the agent opted out of host-machine skill merging.
// The dir is placed under the task workdir when available so it lives and
// dies with the task's storage allocation; otherwise it falls back to the
// OS temp dir.
//
// The dir is NOT empty: every entry from the effective host Claude config
// (resolveHostClaudeConfigDir) is mirrored through *except* `skills/`. That
// preserves Claude Code's login state (`.credentials.json` on Linux/Windows),
// global settings, plugins, agents, commands, output styles, todos, etc. —
// which all live in this directory and would otherwise be invisible to the
// isolated child — while still keeping the user-global skills directory off
// the CLI's discovery path. Without this passthrough, opting into "ignore"
// mode on a non-API-key host would force the Claude agent to re-authenticate,
// breaking the documented "run `claude` once to log in" install flow.
//
// Each entry uses createDirLink / createFileLink, which try symlink first
// and fall back to a directory junction (Windows `mklink /J`) for dirs or a
// hardlink/copy for files when the platform rejects the symlink (Windows
// without Developer Mode / admin). Failing silently to symlink-only would
// leave Windows hosts with an empty scratch dir, defeating the auth
// passthrough — see MUL-2603 review.
//
// The returned cleanup removes the scratch directory; symlinks, junctions,
// and hardlinks are unlinked without touching the real host file. Safe to
// call from any goroutine exactly once.
func newIsolatedClaudeConfigDir(taskCwd, hostConfigDir string, logger *slog.Logger) (string, func(), error) {
parent := taskCwd
if parent == "" {
parent = os.TempDir()
}
dir, err := os.MkdirTemp(parent, "multica-claude-config-*")
if err != nil {
// Fall back to the OS temp dir if the cwd refused us (read-only
// volume, missing parent, etc.). A scratch dir somewhere on the
// host is still strictly better than letting the CLI auto-discover
// `~/.claude/skills/`.
if parent != os.TempDir() {
dir, err = os.MkdirTemp(os.TempDir(), "multica-claude-config-*")
}
if err != nil {
return "", nil, err
}
}
if hostConfigDir != "" {
// Best effort. If the host config dir is missing entirely (env-var-
// auth-only setup) we just leave the scratch dir empty. If individual
// entries fail to mirror — even after fallback — we log so operators
// can correlate Claude Code auth failures with the missing mirror,
// but we do not abort: a partial mirror is still better than letting
// the CLI discover `~/.claude/skills/` directly.
if err := mirrorHostClaudeExceptSkills(hostConfigDir, dir); err != nil && logger != nil {
logger.Warn("claude: mirror host config dir failed",
"source", hostConfigDir,
"dest", dir,
"error", err,
)
}
}
cleanup := func() {
// Best effort — caller logs through normal channels if cleanup fails
// (a leftover scratch dir is non-fatal; the GC sweeper that reclaims
// orphaned task workdirs will catch it too). RemoveAll unlinks
// symlinks / junctions without following them, so the real host
// `~/.claude/` is not touched. Hardlinks share inodes so unlinking
// the mirror entry leaves the source file with refcount 1.
_ = os.RemoveAll(dir)
}
return dir, cleanup, nil
}
// mirrorHostClaudeExceptSkills mirrors every direct entry under
// hostClaudeDir into destDir, skipping `skills/`. Each entry tries a
// symlink first and falls back to a directory junction (Windows
// `mklink /J`) or a hardlink/copy (Windows without Developer Mode) so the
// mirror still works on hosts that lack SeCreateSymbolicLinkPrivilege. The
// first per-entry error is returned so callers can log it; we do not abort
// the loop, because the goal — keeping `skills/` out of the CLI's discovery
// path — is met as soon as we own the scratch dir, and a partial mirror is
// still better than no isolation.
func mirrorHostClaudeExceptSkills(hostClaudeDir, destDir string) error {
return mirrorHostClaudeExceptSkillsWith(hostClaudeDir, destDir, createDirLink, createFileLink)
}
// mirrorHostClaudeExceptSkillsWith is the testable seam behind
// mirrorHostClaudeExceptSkills. Tests inject custom dirLink / fileLink
// closures to exercise the symlink-failure fallback path on platforms
// (Linux/macOS) where os.Symlink would otherwise always succeed.
func mirrorHostClaudeExceptSkillsWith(
hostClaudeDir, destDir string,
dirLink, fileLink func(src, dst string) error,
) error {
entries, err := os.ReadDir(hostClaudeDir)
if err != nil {
return err
}
var firstErr error
for _, entry := range entries {
if entry.Name() == "skills" {
continue
}
src := filepath.Join(hostClaudeDir, entry.Name())
dst := filepath.Join(destDir, entry.Name())
var linkErr error
if entry.IsDir() {
linkErr = dirLink(src, dst)
} else {
linkErr = fileLink(src, dst)
}
if linkErr != nil && firstErr == nil {
firstErr = fmt.Errorf("link %s: %w", entry.Name(), linkErr)
}
}
return firstErr
}
// copyFile copies the bytes of src into dst with a fresh file. Used as the
// last-resort fallback inside createFileLink on Windows when both symlink
// and hardlink are unavailable. Kept in the platform-agnostic file so the
// Unix build still compiles (it never calls copyFile, but the symbol must
// exist for the test fallback seam to be exercisable on Linux/macOS).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open %s: %w", src, err)
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("create %s: %w", dst, err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy %s → %s: %w", src, dst, err)
}
return nil
}
// blockedArgMode specifies whether a blocked arg takes a value or is standalone.
type blockedArgMode int
-20
View File
@@ -1,20 +0,0 @@
//go:build !windows
package agent
import "os"
// createDirLink symlinks src → dst for a directory entry mirrored into the
// per-task scratch CLAUDE_CONFIG_DIR. On Unix the kernel always honours
// symlinks, so a single os.Symlink call is sufficient and matches the
// existing behaviour.
func createDirLink(src, dst string) error {
return os.Symlink(src, dst)
}
// createFileLink symlinks src → dst for a file entry mirrored into the
// per-task scratch CLAUDE_CONFIG_DIR. Unix has no equivalent of Windows's
// SeCreateSymbolicLinkPrivilege gate, so a single os.Symlink is enough.
func createFileLink(src, dst string) error {
return os.Symlink(src, dst)
}
-45
View File
@@ -1,45 +0,0 @@
//go:build windows
package agent
import (
"fmt"
"os"
"os/exec"
)
// createDirLink mirrors a directory from src to dst inside the per-task
// scratch CLAUDE_CONFIG_DIR. Windows os.Symlink requires
// SeCreateSymbolicLinkPrivilege (Developer Mode or admin), which most
// Multica CLI / Desktop installs do not have — without a fallback the
// mirror leaves an empty `~/.claude/`-equivalent, and the default
// "ignore" mode silently breaks Claude Code authentication on those
// hosts. A directory junction (`mklink /J`) does not require the
// privilege and behaves equivalently for our read-only mirror use, so
// it is the first fallback. See server/internal/daemon/execenv for the
// same pattern applied to CODEX_HOME.
func createDirLink(src, dst string) error {
if err := os.Symlink(src, dst); err == nil {
return nil
}
out, err := exec.Command("cmd", "/c", "mklink", "/J", dst, src).CombinedOutput()
if err != nil {
return fmt.Errorf("mklink /J %s %s: %s: %w", dst, src, out, err)
}
return nil
}
// createFileLink mirrors a file from src to dst inside the per-task scratch
// CLAUDE_CONFIG_DIR. Tries symlink first, then hardlink (works on the same
// NTFS volume without elevated privileges and still shares content with the
// shared `~/.claude/.credentials.json` so token refreshes propagate), then
// a final content copy as a last resort.
func createFileLink(src, dst string) error {
if err := os.Symlink(src, dst); err == nil {
return nil
}
if err := os.Link(src, dst); err == nil {
return nil
}
return copyFile(src, dst)
}
-703
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
@@ -651,367 +650,6 @@ func mustMarshal(t *testing.T, v any) json.RawMessage {
return data
}
func TestClaudeExecuteIsolatesHostSkillsWhenIgnoreOptedIn(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// Fake claude binary that prints its CLAUDE_CONFIG_DIR to stdout so we
// can confirm the runtime redirected the CLI off `~/.claude/` when the
// agent explicitly opted into "ignore" mode (the platform default is
// "merge", which preserves the host's CLAUDE_CONFIG_DIR).
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"cat >/dev/null\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"system\\\",\\\"session_id\\\":\\\"sess\\\"}\"\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"result\\\",\\\"subtype\\\":\\\"success\\\",\\\"is_error\\\":false,\\\"session_id\\\":\\\"sess\\\",\\\"result\\\":\\\"$CLAUDE_CONFIG_DIR\\\"}\"\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("claude", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cwd := t.TempDir()
// Explicit SkillsLocal == "ignore" → backend points CLAUDE_CONFIG_DIR at
// a per-task scratch dir under cwd.
session, err := backend.Execute(ctx, "ignored", ExecOptions{
Cwd: cwd,
Timeout: 5 * time.Second,
SkillsLocal: "ignore",
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected completed, got %q (err=%q)", result.Status, result.Error)
}
// The CLI saw a CLAUDE_CONFIG_DIR pointed at a multica-managed scratch
// dir under our task cwd, not the host user's ~/.claude.
got := strings.TrimSpace(result.Output)
if got == "" {
t.Fatalf("expected CLAUDE_CONFIG_DIR to be non-empty in ignore mode")
}
if !strings.Contains(got, "multica-claude-config-") {
t.Fatalf("expected isolated scratch dir, got %q", got)
}
if !strings.HasPrefix(got, cwd) {
t.Fatalf("expected isolated dir under %q, got %q", cwd, got)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func TestClaudeExecuteDefaultModeKeepsHostConfigDir(t *testing.T) {
// NOT parallel — t.Setenv mutates global env.
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// Default ExecOptions (no SkillsLocal) must preserve the host's
// CLAUDE_CONFIG_DIR — the platform default is "merge", which inherits
// the host's user-global skill directory (Bohan's product decision on
// MUL-2603: keep MUL-2603 hardening as an explicit opt-in to avoid
// regressing personal workflows that rely on locally installed skills).
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"cat >/dev/null\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"system\\\",\\\"session_id\\\":\\\"sess\\\"}\"\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"result\\\",\\\"subtype\\\":\\\"success\\\",\\\"is_error\\\":false,\\\"session_id\\\":\\\"sess\\\",\\\"result\\\":\\\"${CLAUDE_CONFIG_DIR:-unset}\\\"}\"\n"
writeTestExecutable(t, fakePath, []byte(script))
t.Setenv("CLAUDE_CONFIG_DIR", "/tmp/test-host-claude")
backend, err := New("claude", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Default ExecOptions → SkillsLocal == "" → backend treats as merge
// (inherit-from-machine) and the host CLAUDE_CONFIG_DIR is preserved.
session, err := backend.Execute(ctx, "ignored", ExecOptions{
Cwd: t.TempDir(),
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected completed, got %q (err=%q)", result.Status, result.Error)
}
got := strings.TrimSpace(result.Output)
if got != "/tmp/test-host-claude" {
t.Fatalf("expected host CLAUDE_CONFIG_DIR preserved in default mode, got %q", got)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func TestClaudeExecuteMergeModeKeepsHostConfigDir(t *testing.T) {
// NOT parallel — t.Setenv mutates global env which conflicts with
// concurrent tests reading CLAUDE_CONFIG_DIR or running under t.Parallel.
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"cat >/dev/null\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"system\\\",\\\"session_id\\\":\\\"sess\\\"}\"\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"result\\\",\\\"subtype\\\":\\\"success\\\",\\\"is_error\\\":false,\\\"session_id\\\":\\\"sess\\\",\\\"result\\\":\\\"${CLAUDE_CONFIG_DIR:-unset}\\\"}\"\n"
writeTestExecutable(t, fakePath, []byte(script))
// Set a host-style CLAUDE_CONFIG_DIR so we can assert merge mode
// preserves it. The backend strips this in ignore mode but must leave
// it alone when the operator explicitly opted into merging.
t.Setenv("CLAUDE_CONFIG_DIR", "/tmp/test-host-claude")
backend, err := New("claude", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "ignored", ExecOptions{
Cwd: t.TempDir(),
Timeout: 5 * time.Second,
SkillsLocal: "merge",
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected completed, got %q (err=%q)", result.Status, result.Error)
}
got := strings.TrimSpace(result.Output)
if got != "/tmp/test-host-claude" {
t.Fatalf("expected host CLAUDE_CONFIG_DIR preserved in merge mode, got %q", got)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func TestBuildClaudeEnvAppendsIsolatedConfigDir(t *testing.T) {
t.Parallel()
env := buildClaudeEnv(nil, "/tmp/isolated-claude-config")
var last string
hits := 0
for _, entry := range env {
if strings.HasPrefix(entry, "CLAUDE_CONFIG_DIR=") {
hits++
last = entry
}
}
if hits != 1 {
t.Fatalf("expected exactly one CLAUDE_CONFIG_DIR entry, got %d (%v)", hits, env)
}
if last != "CLAUDE_CONFIG_DIR=/tmp/isolated-claude-config" {
t.Fatalf("expected isolated CLAUDE_CONFIG_DIR override, got %q", last)
}
}
func TestBuildClaudeEnvSkipsOverrideWhenEmpty(t *testing.T) {
t.Parallel()
// Asking for "merge" mode passes "" through. We should not add a
// CLAUDE_CONFIG_DIR=… entry; the parent's value (if any) wins.
env := buildClaudeEnv(map[string]string{"FOO": "bar"}, "")
for _, entry := range env {
if strings.HasPrefix(entry, "CLAUDE_CONFIG_DIR=") {
// The parent env may legitimately have one set on a developer
// machine — only assert we did not *add* one. Find the unfiltered
// merge case to compare.
return
}
}
}
func TestBuildClaudeEnvOverridesPreviousValue(t *testing.T) {
t.Parallel()
// Even if custom_env supplies a CLAUDE_CONFIG_DIR, the isolated dir
// must take precedence: a stale custom_env entry must never be able
// to point the child back at `~/.claude/`.
env := buildClaudeEnv(map[string]string{"CLAUDE_CONFIG_DIR": "/etc/hostile"}, "/tmp/safe")
hits := 0
for _, entry := range env {
if entry == "CLAUDE_CONFIG_DIR=/etc/hostile" {
t.Fatalf("hostile custom_env CLAUDE_CONFIG_DIR was not stripped: %v", env)
}
if entry == "CLAUDE_CONFIG_DIR=/tmp/safe" {
hits++
}
}
if hits != 1 {
t.Fatalf("expected isolated dir to be present exactly once, got %d (%v)", hits, env)
}
}
func TestNewIsolatedClaudeConfigDirCreatesAndCleansUp(t *testing.T) {
t.Parallel()
parent := t.TempDir()
dir, cleanup, err := newIsolatedClaudeConfigDir(parent, "", slog.Default())
if err != nil {
t.Fatalf("newIsolatedClaudeConfigDir: %v", err)
}
defer cleanup()
if dir == "" {
t.Fatal("expected non-empty dir")
}
if filepath.Dir(dir) != parent {
t.Fatalf("expected dir under %q, got %q", parent, dir)
}
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
t.Fatalf("expected created dir to exist, stat err=%v", err)
}
// The dir may contain symlinks mirrored from the host's ~/.claude/ (auth
// token, settings, etc. — see mirrorHostClaudeExceptSkills). What it must
// NOT contain is a `skills/` entry; that is the entire point of the
// isolation. Mirroring behaviour is exercised in dedicated tests below.
if _, err := os.Lstat(filepath.Join(dir, "skills")); !os.IsNotExist(err) {
t.Fatalf("expected no skills/ entry in isolated dir, stat err=%v", err)
}
// Cleanup is idempotent (Execute may double-defer in error paths).
cleanup()
cleanup()
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("expected dir removed, stat err=%v", err)
}
}
// TestMirrorHostClaudeExceptSkills_PassesAuthAndSkipsSkills locks in the
// invariant Elon flagged in review: the isolation must not also isolate the
// Claude login token. Mirror reaches every non-skills entry — including
// `.credentials.json`, the Linux/Windows store for the OAuth token — so a
// host that has only "run `claude` to log in" (no ANTHROPIC_API_KEY) still
// authenticates inside the isolated config dir.
func TestMirrorHostClaudeExceptSkills_PassesAuthAndSkipsSkills(t *testing.T) {
t.Parallel()
host := t.TempDir()
// Populate a realistic-ish ~/.claude/ layout.
for _, sub := range []string{"skills", "agents", "commands", "plugins"} {
if err := os.MkdirAll(filepath.Join(host, sub), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", sub, err)
}
}
// A broken skill in `skills/`: if the mirror passes it through, the
// regression Elon's review highlighted is back.
if err := os.WriteFile(filepath.Join(host, "skills", "broken.md"), []byte("frontmatter-corrupt"), 0o644); err != nil {
t.Fatalf("write broken skill: %v", err)
}
// The OAuth credential file is the asset the reviewer specifically flagged.
if err := os.WriteFile(filepath.Join(host, ".credentials.json"), []byte(`{"token":"abc"}`), 0o600); err != nil {
t.Fatalf("write credentials: %v", err)
}
if err := os.WriteFile(filepath.Join(host, "settings.json"), []byte(`{}`), 0o644); err != nil {
t.Fatalf("write settings: %v", err)
}
dest := t.TempDir()
if err := mirrorHostClaudeExceptSkills(host, dest); err != nil {
t.Fatalf("mirror: %v", err)
}
// skills/ must be absent — claude would otherwise discover the broken
// host skill through the symlink and crash before reading stdin (#3052).
if _, err := os.Lstat(filepath.Join(dest, "skills")); !os.IsNotExist(err) {
t.Fatalf("expected skills/ to be skipped, got stat err=%v", err)
}
// Everything else must reach the isolated dir with the same contents as
// the host source. We resolve through the mirrored entry to confirm the
// CLI sees real bytes whether the implementation used a symlink (Unix),
// a junction (Windows), a hardlink (Windows no Developer Mode, same
// volume), or a content copy (last-resort fallback). os.Stat follows
// every variant, so the assertion is platform-agnostic.
for _, expected := range []string{".credentials.json", "settings.json", "agents", "commands", "plugins"} {
dst := filepath.Join(dest, expected)
if _, err := os.Stat(dst); err != nil {
t.Fatalf("expected %s mirrored and reachable, stat err=%v", expected, err)
}
}
// The credential file should round-trip — claude reading
// $CLAUDE_CONFIG_DIR/.credentials.json must see the live host token.
got, err := os.ReadFile(filepath.Join(dest, ".credentials.json"))
if err != nil {
t.Fatalf("read mirrored credentials: %v", err)
}
if string(got) != `{"token":"abc"}` {
t.Fatalf("mirrored credentials content drifted, got %q", got)
}
}
// TestMirrorHostClaudeExceptSkills_MissingHostDirIsNoop documents that a host
// with no `~/.claude/` (env-var-auth-only setups) is a supported state, not
// an error. The isolated dir simply stays empty.
func TestMirrorHostClaudeExceptSkills_MissingHostDirIsNoop(t *testing.T) {
t.Parallel()
dest := t.TempDir()
err := mirrorHostClaudeExceptSkills(filepath.Join(t.TempDir(), "nope"), dest)
if err == nil {
t.Fatal("expected a read error for missing host dir")
}
// dest must remain untouched (no skills/, no other entries).
entries, _ := os.ReadDir(dest)
if len(entries) != 0 {
t.Fatalf("expected empty dest on missing host, got %d entries", len(entries))
}
}
func TestNewIsolatedClaudeConfigDirFallsBackToTemp(t *testing.T) {
t.Parallel()
// Empty cwd → falls back to OS temp dir without erroring.
dir, cleanup, err := newIsolatedClaudeConfigDir("", "", slog.Default())
if err != nil {
t.Fatalf("expected fallback to OS temp, got err=%v", err)
}
defer cleanup()
if !strings.HasPrefix(dir, os.TempDir()) {
t.Fatalf("expected fallback under %q, got %q", os.TempDir(), dir)
}
}
func TestBuildClaudeArgsExtraArgsBeforeCustomArgsAndFiltersBoth(t *testing.T) {
args := buildClaudeArgs(ExecOptions{
ExtraArgs: []string{"--output-format", "text", "--max-budget-usd", "1.00"},
@@ -1034,344 +672,3 @@ func TestBuildClaudeArgsExtraArgsBeforeCustomArgsAndFiltersBoth(t *testing.T) {
t.Fatalf("expected extra args before custom args, got %v", args)
}
}
// TestResolveHostClaudeConfigDir locks in the precedence Elon's review asked
// for: agent custom_env wins over a daemon-host CLAUDE_CONFIG_DIR, which
// wins over the documented default at `~/.claude`. Without this, switching
// default mode to "ignore" would mirror the wrong source dir and overwrite
// a valid CLAUDE_CONFIG_DIR pointing at e.g. a managed shared profile.
func TestResolveHostClaudeConfigDir(t *testing.T) {
// NOT parallel — t.Setenv mutates global env which conflicts with
// concurrent tests reading CLAUDE_CONFIG_DIR.
// 1. Agent custom_env wins over parent env.
t.Setenv("CLAUDE_CONFIG_DIR", "/from/parent/env")
got := resolveHostClaudeConfigDir(map[string]string{"CLAUDE_CONFIG_DIR": "/from/custom/env"})
if got != "/from/custom/env" {
t.Fatalf("agent custom_env should win, got %q", got)
}
// 2. Empty custom_env falls back to parent env.
got = resolveHostClaudeConfigDir(map[string]string{"CLAUDE_CONFIG_DIR": ""})
if got != "/from/parent/env" {
t.Fatalf("parent env should win when custom_env is empty, got %q", got)
}
got = resolveHostClaudeConfigDir(nil)
if got != "/from/parent/env" {
t.Fatalf("parent env should win when custom_env is nil, got %q", got)
}
// 3. With neither set, falls back to `~/.claude`.
t.Setenv("CLAUDE_CONFIG_DIR", "")
got = resolveHostClaudeConfigDir(nil)
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no UserHomeDir on this host: %v", err)
}
want := filepath.Join(home, ".claude")
if got != want {
t.Fatalf("default should be %q, got %q", want, got)
}
}
// TestNewIsolatedClaudeConfigDirMirrorsCustomHostDir confirms the scratch
// dir reflects the effective CLAUDE_CONFIG_DIR source, not unconditionally
// `~/.claude/`. Previously the mirror was hardcoded to UserHomeDir, so an
// operator who pinned CLAUDE_CONFIG_DIR at a managed install would get the
// wrong credentials in the scratch dir.
func TestNewIsolatedClaudeConfigDirMirrorsCustomHostDir(t *testing.T) {
t.Parallel()
host := t.TempDir()
if err := os.WriteFile(filepath.Join(host, ".credentials.json"), []byte(`{"token":"from-custom-host"}`), 0o600); err != nil {
t.Fatalf("seed credentials: %v", err)
}
dir, cleanup, err := newIsolatedClaudeConfigDir(t.TempDir(), host, slog.Default())
if err != nil {
t.Fatalf("newIsolatedClaudeConfigDir: %v", err)
}
defer cleanup()
got, err := os.ReadFile(filepath.Join(dir, ".credentials.json"))
if err != nil {
t.Fatalf("read mirrored credentials from custom host: %v", err)
}
if string(got) != `{"token":"from-custom-host"}` {
t.Fatalf("mirror sourced from wrong dir: got %q", got)
}
}
// TestNewIsolatedClaudeConfigDirEmptyHostIsNoop documents the env-var-auth
// case: with no host config dir (host has no `~/.claude/` and no
// CLAUDE_CONFIG_DIR set anywhere), the scratch dir is created but empty and
// nothing is mirrored. The CLI runs with `ANTHROPIC_API_KEY` only.
func TestNewIsolatedClaudeConfigDirEmptyHostIsNoop(t *testing.T) {
t.Parallel()
dir, cleanup, err := newIsolatedClaudeConfigDir(t.TempDir(), "", slog.Default())
if err != nil {
t.Fatalf("newIsolatedClaudeConfigDir: %v", err)
}
defer cleanup()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read scratch dir: %v", err)
}
if len(entries) != 0 {
t.Fatalf("expected empty scratch dir with no host source, got %d entries", len(entries))
}
}
// TestMirrorHostClaudeExceptSkillsWith_FallbackWhenSymlinkFails locks in the
// Windows-no-Developer-Mode behaviour Elon's review asked for: when symlink
// raises a permission error, the mirror still places the entry in the
// scratch dir via a fallback (junction for dirs, hardlink/copy for files).
// Tested via the lower-level seam so the assertion runs on Linux/macOS CI;
// the production createDirLink / createFileLink wrappers encode the same
// "try symlink first, then fall back" chain in their platform builds.
func TestMirrorHostClaudeExceptSkillsWith_FallbackWhenSymlinkFails(t *testing.T) {
t.Parallel()
host := t.TempDir()
if err := os.WriteFile(filepath.Join(host, ".credentials.json"), []byte(`{"token":"fallback"}`), 0o600); err != nil {
t.Fatalf("seed credentials: %v", err)
}
if err := os.MkdirAll(filepath.Join(host, "agents"), 0o755); err != nil {
t.Fatalf("seed agents dir: %v", err)
}
if err := os.WriteFile(filepath.Join(host, "agents", "global.md"), []byte("global agent"), 0o600); err != nil {
t.Fatalf("seed agent file: %v", err)
}
// `skills/` must still be skipped even when the linker reports an
// error — the broken-skill GitHub #3052 regression must not slip back.
if err := os.MkdirAll(filepath.Join(host, "skills"), 0o755); err != nil {
t.Fatalf("seed skills dir: %v", err)
}
if err := os.WriteFile(filepath.Join(host, "skills", "broken.md"), []byte("frontmatter-corrupt"), 0o600); err != nil {
t.Fatalf("seed broken skill: %v", err)
}
dest := t.TempDir()
// Simulated "Windows without Developer Mode": symlink always returns
// EPERM. The fallback path must still land the entry in dest.
failedSymlinkAttempts := 0
fakeSymlinkErr := errors.New("simulated EPERM: symlink not permitted")
dirLink := func(src, dst string) error {
if err := os.Symlink(src, dst); err == nil {
// Forcing failure: if a symlink would have worked, pretend it
// didn't and engage the junction equivalent. We mimic the
// junction by using os.MkdirAll (a junction behaves like a
// directory entry from userspace) and copying the immediate
// child files into it. For the test we only need the entry to
// exist and be reachable; we do not need real recursive
// equivalence.
_ = os.Remove(dst)
}
failedSymlinkAttempts++
_ = fakeSymlinkErr // referenced so the simulated-EPERM error is documented in the test body
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
// Copy any direct children so the destination is non-empty and
// the test can read through it.
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
data, err := os.ReadFile(filepath.Join(src, e.Name()))
if err != nil {
continue
}
_ = os.WriteFile(filepath.Join(dst, e.Name()), data, 0o600)
}
return nil
}
fileLink := func(src, dst string) error {
if err := os.Symlink(src, dst); err == nil {
_ = os.Remove(dst)
}
failedSymlinkAttempts++
// Hardlink fallback first (this is what createFileLink does on
// Windows when symlink is denied but the source/dest share a
// volume). If hardlink also fails (e.g. cross-volume), fall back
// to a content copy.
if err := os.Link(src, dst); err == nil {
return nil
}
return copyFile(src, dst)
}
if err := mirrorHostClaudeExceptSkillsWith(host, dest, dirLink, fileLink); err != nil {
t.Fatalf("mirror with failing symlink: %v", err)
}
if failedSymlinkAttempts == 0 {
t.Fatalf("expected fallback path to engage at least once")
}
// `.credentials.json` must round-trip through whatever fallback the
// file linker used (hardlink or copy). This is the assertion Elon's
// review pinned to "no `.credentials.json` ⇒ default ignore breaks
// Claude Code auth on Windows".
got, err := os.ReadFile(filepath.Join(dest, ".credentials.json"))
if err != nil {
t.Fatalf("read mirrored credentials after fallback: %v", err)
}
if string(got) != `{"token":"fallback"}` {
t.Fatalf("mirrored credentials drifted after fallback, got %q", got)
}
// Sub-directories must also reach the destination via the dir
// fallback (junction equivalent). We test reachability + child
// content rather than the underlying file kind, because junctions,
// symlinks, and the test's copy-based stand-in all present the same
// userspace view.
if _, err := os.Stat(filepath.Join(dest, "agents")); err != nil {
t.Fatalf("agents/ not mirrored: %v", err)
}
gotChild, err := os.ReadFile(filepath.Join(dest, "agents", "global.md"))
if err != nil {
t.Fatalf("read agent child file: %v", err)
}
if string(gotChild) != "global agent" {
t.Fatalf("agent child content drifted, got %q", gotChild)
}
// `skills/` must be absent regardless of fallback engagement.
if _, err := os.Lstat(filepath.Join(dest, "skills")); !os.IsNotExist(err) {
t.Fatalf("skills/ leaked into scratch dir on fallback path, stat err=%v", err)
}
}
// TestMirrorHostClaudeExceptSkillsWith_PropagatesFirstLinkError makes sure
// callers see a per-entry link failure when even the fallback fails — the
// scratch-dir caller logs the error so operators chasing auth issues on
// Windows can correlate the missing mirror with their permission setup.
func TestMirrorHostClaudeExceptSkillsWith_PropagatesFirstLinkError(t *testing.T) {
t.Parallel()
host := t.TempDir()
if err := os.WriteFile(filepath.Join(host, ".credentials.json"), []byte("x"), 0o600); err != nil {
t.Fatalf("seed credentials: %v", err)
}
hardFail := errors.New("link refused after fallback")
fail := func(src, dst string) error { return hardFail }
err := mirrorHostClaudeExceptSkillsWith(host, t.TempDir(), fail, fail)
if err == nil {
t.Fatal("expected an error when every linker fails")
}
if !errors.Is(err, hardFail) {
t.Fatalf("expected wrapped hardFail, got %v", err)
}
}
// TestCopyFileRoundTrip exercises the last-resort content-copy fallback used
// by createFileLink on Windows when both symlink and hardlink are
// unavailable (e.g. cross-volume scratch dir). The copy must produce a
// byte-for-byte equivalent destination so Claude Code reads the real
// credential bytes.
func TestCopyFileRoundTrip(t *testing.T) {
t.Parallel()
src := filepath.Join(t.TempDir(), "creds")
if err := os.WriteFile(src, []byte(`{"token":"abc"}`), 0o600); err != nil {
t.Fatalf("seed source: %v", err)
}
dst := filepath.Join(t.TempDir(), "creds-copy")
if err := copyFile(src, dst); err != nil {
t.Fatalf("copyFile: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read copy: %v", err)
}
if string(got) != `{"token":"abc"}` {
t.Fatalf("copy drifted, got %q", got)
}
// EXCL semantics — copyFile refuses to overwrite an existing file so
// a stale mirror entry never silently shadows a fresh source.
if err := copyFile(src, dst); err == nil {
t.Fatal("expected copyFile to refuse overwriting existing dst")
}
}
// TestClaudeExecuteIsolatesUsesCustomEnvSource confirms the runtime mirrors
// from the agent's custom_env CLAUDE_CONFIG_DIR — the exact bug Elon's
// review flagged: when an operator pins CLAUDE_CONFIG_DIR via custom_env,
// the scratch dir must mirror *that* source, not `~/.claude`. Otherwise
// default `ignore` mode would silently load the wrong credentials.
func TestClaudeExecuteIsolatesUsesCustomEnvSource(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// Build a synthetic "host Claude config dir" the agent will pin via
// custom_env. The mirror should land its `.credentials.json` in the
// scratch dir. The token value is a plain quote-free string so the
// fake-claude shell script can echo it through stream-json's `result`
// field without escape gymnastics.
customHost := t.TempDir()
const expectedToken = "from-custom-host-token-ok"
if err := os.WriteFile(filepath.Join(customHost, ".credentials.json"), []byte(expectedToken), 0o600); err != nil {
t.Fatalf("seed credentials: %v", err)
}
// Fake claude binary that prints the mirrored credentials content from
// the scratch CLAUDE_CONFIG_DIR — we then assert that we see the
// custom host's token, not whatever lives in real `~/.claude/`.
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"cat >/dev/null\n" +
"creds=$(cat \"$CLAUDE_CONFIG_DIR/.credentials.json\" 2>/dev/null || echo MISSING)\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"system\\\",\\\"session_id\\\":\\\"sess\\\"}\"\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"result\\\",\\\"subtype\\\":\\\"success\\\",\\\"is_error\\\":false,\\\"session_id\\\":\\\"sess\\\",\\\"result\\\":\\\"$creds\\\"}\"\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("claude", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{"CLAUDE_CONFIG_DIR": customHost},
})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Explicit SkillsLocal == "ignore" → backend builds scratch dir mirrored
// from custom_env CLAUDE_CONFIG_DIR. (The platform default is "merge",
// which would just preserve the host CLAUDE_CONFIG_DIR untouched and
// never exercise the mirror path — see MUL-2603 product decision.)
session, err := backend.Execute(ctx, "ignored", ExecOptions{
Cwd: t.TempDir(),
Timeout: 5 * time.Second,
SkillsLocal: "ignore",
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected completed, got %q (err=%q)", result.Status, result.Error)
}
got := strings.TrimSpace(result.Output)
if got != expectedToken {
t.Fatalf("expected credentials mirrored from custom CLAUDE_CONFIG_DIR, got %q", got)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
+194 -12
View File
@@ -26,14 +26,40 @@ var codexBlockedArgs = map[string]blockedArgMode{
// 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
defaultCodexSemanticInactivityTimeout = 10 * time.Minute
codexStderrTailBytes = 2048
defaultCodexSemanticInactivityTimeout = 10 * time.Minute
defaultCodexFirstTurnNoProgressTimeout = 30 * time.Second
codexVersionDiagnosticTimeout = 2 * time.Second
)
// CodexSemanticInactivityMarker prefixes timeout errors emitted when Codex
// stops making semantic progress while the process is still alive.
const CodexSemanticInactivityMarker = "codex semantic inactivity timeout"
// CodexFirstTurnNoProgressMarker identifies the app-server failure mode where
// Codex accepts a turn and then never emits any item, completion, or error.
const CodexFirstTurnNoProgressMarker = "codex app-server no progress timeout"
const codexModelCatalogRefreshTimeoutSignal = "failed to refresh available models: timeout waiting for child process to exit"
type codexTimeoutKind int
const (
codexTimeoutNone codexTimeoutKind = iota
codexTimeoutSemanticInactivity
codexTimeoutFirstTurnNoProgress
)
type codexTimeoutDiagnostic struct {
Kind codexTimeoutKind
Timeout time.Duration
LastActivity string
ThreadID string
TurnID string
Model string
CodexVersion string
}
// codexBackend implements Backend by spawning `codex app-server --listen stdio://`
// and communicating via JSON-RPC 2.0 over stdin/stdout.
type codexBackend struct {
@@ -244,7 +270,22 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
semanticTimer := time.NewTimer(semanticInactivityTimeout)
defer semanticTimer.Stop()
firstTurnNoProgressTimeout := codexFirstTurnNoProgressTimeout(semanticInactivityTimeout)
var firstTurnNoProgressTimer *time.Timer
var firstTurnNoProgressTimerC <-chan time.Time
firstTurnStarted := false
firstTurnProgressObserved := false
stopFirstTurnNoProgressTimer := func() {
if firstTurnNoProgressTimer == nil {
return
}
stopTimer(firstTurnNoProgressTimer)
firstTurnNoProgressTimerC = nil
}
defer stopFirstTurnNoProgressTimer()
waitingForTurn := true
var timeoutDiagnostic codexTimeoutDiagnostic
for waitingForTurn {
select {
case aborted := <-turnDone:
@@ -263,10 +304,43 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
lastSemanticActivity = time.Now()
lastSemanticActivityDescription = activity
resetTimer(semanticTimer, semanticInactivityTimeout)
if activity == "status:running" && !firstTurnStarted {
firstTurnStarted = true
firstTurnNoProgressTimer = time.NewTimer(firstTurnNoProgressTimeout)
firstTurnNoProgressTimerC = firstTurnNoProgressTimer.C
} else if firstTurnStarted && !firstTurnProgressObserved && isCodexFirstTurnProgressActivity(activity) {
firstTurnProgressObserved = true
stopFirstTurnNoProgressTimer()
}
case <-firstTurnNoProgressTimerC:
waitingForTurn = false
finalStatus = "timeout"
timeoutDiagnostic = codexTimeoutDiagnostic{
Kind: codexTimeoutFirstTurnNoProgress,
Timeout: firstTurnNoProgressTimeout,
LastActivity: lastSemanticActivityDescription,
ThreadID: threadID,
TurnID: c.turnID,
Model: opts.Model,
}
b.cfg.Logger.Warn(CodexFirstTurnNoProgressMarker,
"pid", cmd.Process.Pid,
"thread_id", threadID,
"turn_id", c.turnID,
"timeout", firstTurnNoProgressTimeout.String(),
"last_activity", lastSemanticActivityDescription,
)
case <-semanticTimer.C:
waitingForTurn = false
finalStatus = "timeout"
finalError = fmt.Sprintf("%s after %s without agent progress (last activity: %s)", CodexSemanticInactivityMarker, semanticInactivityTimeout, lastSemanticActivityDescription)
timeoutDiagnostic = codexTimeoutDiagnostic{
Kind: codexTimeoutSemanticInactivity,
Timeout: semanticInactivityTimeout,
LastActivity: lastSemanticActivityDescription,
ThreadID: threadID,
TurnID: c.turnID,
Model: opts.Model,
}
b.cfg.Logger.Warn(CodexSemanticInactivityMarker,
"pid", cmd.Process.Pid,
"thread_id", threadID,
@@ -298,6 +372,12 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
// Wait for the reader goroutine to finish so all output is accumulated.
<-readerDone
drainAndWait()
if timeoutDiagnostic.Kind != codexTimeoutNone {
timeoutDiagnostic.CodexVersion = detectCodexVersionForDiagnostics(context.Background(), execPath, cmd.Env, b.cfg.Logger)
finalError = buildCodexTimeoutDiagnosticError(timeoutDiagnostic, stderrBuf.Tail())
}
outputMu.Lock()
finalOutput := output.String()
@@ -446,6 +526,106 @@ func resetTimer(timer *time.Timer, d time.Duration) {
timer.Reset(d)
}
func stopTimer(timer *time.Timer) {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
func codexFirstTurnNoProgressTimeout(semanticInactivityTimeout time.Duration) time.Duration {
if semanticInactivityTimeout <= 0 || semanticInactivityTimeout > defaultCodexFirstTurnNoProgressTimeout {
return defaultCodexFirstTurnNoProgressTimeout
}
scaled := semanticInactivityTimeout * 4 / 5
if scaled <= 0 {
return semanticInactivityTimeout
}
return scaled
}
func isCodexFirstTurnProgressActivity(activity string) bool {
return activity != "" && activity != "status:running" && activity != "error:retry"
}
func buildCodexTimeoutDiagnosticError(diag codexTimeoutDiagnostic, stderrTail string) string {
var msg string
switch diag.Kind {
case codexTimeoutFirstTurnNoProgress:
msg = fmt.Sprintf("%s after %s: received turn start but no item, message, tool, turn/completed, or error event (%s)",
CodexFirstTurnNoProgressMarker,
diag.Timeout,
formatCodexDiagnosticFields(diag),
)
case codexTimeoutSemanticInactivity:
msg = fmt.Sprintf("%s after %s without agent progress (last activity: %s; %s)",
CodexSemanticInactivityMarker,
diag.Timeout,
nonEmptyCodexDiagnosticValue(diag.LastActivity),
formatCodexDiagnosticFields(diag),
)
default:
msg = "codex timed out"
}
msg = appendCodexKnownStderrHint(msg, stderrTail)
return withAgentStderr(msg, "codex", stderrTail)
}
func formatCodexDiagnosticFields(diag codexTimeoutDiagnostic) string {
return fmt.Sprintf("codex_version=%q thread_id=%q turn_id=%q model=%q",
nonEmptyCodexDiagnosticValue(diag.CodexVersion),
nonEmptyCodexDiagnosticValue(diag.ThreadID),
nonEmptyCodexDiagnosticValue(diag.TurnID),
formatCodexDiagnosticModel(diag.Model),
)
}
func nonEmptyCodexDiagnosticValue(value string) string {
if strings.TrimSpace(value) == "" {
return "unknown"
}
return value
}
func formatCodexDiagnosticModel(model string) string {
if strings.TrimSpace(model) == "" {
return "default(empty)"
}
return model
}
func appendCodexKnownStderrHint(msg, stderrTail string) string {
if strings.Contains(stderrTail, codexModelCatalogRefreshTimeoutSignal) {
return msg + "; diagnosis: Codex stderr shows the model catalog refresh timed out. Try setting an explicit model, switching Codex CLI versions, or using another runtime while Codex app-server recovers"
}
return msg
}
func detectCodexVersionForDiagnostics(ctx context.Context, execPath string, env []string, logger *slog.Logger) string {
versionCtx, cancel := context.WithTimeout(ctx, codexVersionDiagnosticTimeout)
defer cancel()
cmd := exec.CommandContext(versionCtx, execPath, "--version")
cmd.Env = env
data, err := cmd.Output()
if err != nil {
if logger != nil {
logger.Debug("codex version diagnostic failed", "error", err)
}
return "unknown"
}
version := extractVersionLine(string(data))
if strings.TrimSpace(version) == "" {
return "unknown"
}
return version
}
func trySendString(ch chan<- string, value string) {
select {
case ch <- value:
@@ -884,8 +1064,18 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any
}
if errMsg != "" {
c.cfg.Logger.Warn("codex error notification", "message", errMsg, "will_retry", willRetry)
if c.onSemanticActivity != nil {
if willRetry {
c.onSemanticActivity("error:retry")
} else {
c.onSemanticActivity("error:terminal")
}
}
if !willRetry {
c.setTurnError(errMsg)
if c.onTurnDone != nil {
c.onTurnDone(false)
}
}
}
@@ -971,15 +1161,7 @@ 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
}
return strings.HasPrefix(method, "item/")
}
func describeCodexItemProgressActivity(method, itemType, itemID string) string {
+165 -2
View File
@@ -456,12 +456,29 @@ func TestCodexRawErrorNotificationTerminal(t *testing.T) {
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
done := false
var activities []string
c.onSemanticActivity = func(activity string) {
activities = append(activities, activity)
}
c.onTurnDone = func(aborted bool) {
if aborted {
t.Fatal("terminal error should not mark the turn aborted")
}
done = true
}
c.handleLine(`{"jsonrpc":"2.0","method":"error","params":{"error":{"message":"boom"},"willRetry":false}}`)
if got := c.getTurnError(); got != "boom" {
t.Fatalf("expected terminal error captured, got %q", got)
}
if !done {
t.Fatal("terminal error should finish the turn")
}
if got, want := strings.Join(activities, ","), "error:terminal"; got != want {
t.Fatalf("semantic activity = %q, want %q", got, want)
}
}
func TestCodexRawErrorNotificationRetryingIgnored(t *testing.T) {
@@ -469,12 +486,51 @@ func TestCodexRawErrorNotificationRetryingIgnored(t *testing.T) {
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
var activities []string
c.onSemanticActivity = func(activity string) {
activities = append(activities, activity)
}
c.onTurnDone = func(aborted bool) {
t.Fatal("retrying error should not finish the turn")
}
c.handleLine(`{"jsonrpc":"2.0","method":"error","params":{"error":{"message":"reconnecting"},"willRetry":true}}`)
if got := c.getTurnError(); got != "" {
t.Fatalf("retrying error should not be captured, got %q", got)
}
if got, want := strings.Join(activities, ","), "error:retry"; got != want {
t.Fatalf("semantic activity = %q, want %q", got, want)
}
}
func TestCodexFirstTurnProgressActivity(t *testing.T) {
t.Parallel()
cases := []struct {
activity string
want bool
}{
{activity: "", want: false},
{activity: "status:running", want: false},
{activity: "error:retry", want: false},
{activity: "error", want: true},
{activity: "text", want: true},
{activity: "tool-use:exec_command", want: true},
{activity: "tool-result:exec_command", want: true},
{activity: "item/started:commandExecution:cmd-1", want: true},
{activity: "item/completed:agentMessage:msg-1", want: true},
{activity: "error:terminal", want: true},
{activity: "turn:completed", want: true},
}
for _, tc := range cases {
t.Run(tc.activity, func(t *testing.T) {
if got := isCodexFirstTurnProgressActivity(tc.activity); got != tc.want {
t.Fatalf("isCodexFirstTurnProgressActivity(%q) = %v, want %v", tc.activity, got, tc.want)
}
})
}
}
func TestCodexSetTurnErrorFirstWins(t *testing.T) {
@@ -1107,6 +1163,112 @@ func TestCodexExecuteTimesOutWhenTurnStopsAfterToolResult(t *testing.T) {
}
}
func TestCodexExecuteFirstTurnNoProgressSurfacesDiagnostics(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-stuck"}}}'`+"\n"+
`read line`+"\n"+
`echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+
`echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-stuck","turn":{"id":"turn-stuck"}}}'`+"\n"+
`echo 'ERROR codex_models_manager::manager: failed to refresh available models: timeout waiting for child process to exit' >&2`+"\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)
}
for _, want := range []string{
CodexFirstTurnNoProgressMarker,
"thr-stuck",
"turn-stuck",
`model="default(empty)"`,
`codex_version="codex-cli 0.0.0-test"`,
"model catalog refresh timed out",
"codex stderr:",
codexModelCatalogRefreshTimeoutSignal,
} {
if !strings.Contains(result.Error, want) {
t.Fatalf("expected error to contain %q, got %q", want, result.Error)
}
}
}
func TestCodexExecuteFirstTurnRetryErrorDoesNotSatisfyProgress(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-retry"}}}'`+"\n"+
`read line`+"\n"+
`echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+
`echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-retry","turn":{"id":"turn-retry"}}}'`+"\n"+
`echo '{"jsonrpc":"2.0","method":"error","params":{"threadId":"thr-retry","error":{"message":"temporary reconnect"},"willRetry":true}}'`+"\n"+
`sleep 5`+"\n")
result := executeFakeCodex(t, fakePath, ExecOptions{
Timeout: 5 * time.Second,
SemanticInactivityTimeout: 200 * time.Millisecond,
})
if result.Status != "timeout" {
t.Fatalf("expected timeout, got status=%q error=%q", result.Status, result.Error)
}
if !strings.Contains(result.Error, CodexFirstTurnNoProgressMarker) {
t.Fatalf("expected first-turn no-progress error, got %q", result.Error)
}
if strings.Contains(result.Error, CodexSemanticInactivityMarker) {
t.Fatalf("retrying error should not demote first-turn timeout to semantic inactivity, got %q", result.Error)
}
}
func TestCodexExecuteLegacyFirstTurnMessageSatisfiesProgress(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-legacy"}}}'`+"\n"+
`read line`+"\n"+
`echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+
`echo '{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_started"}}}'`+"\n"+
`sleep 0.05`+"\n"+
`echo '{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"agent_message","message":"legacy alive"}}}'`+"\n"+
`sleep 0.07`+"\n"+
`echo '{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_complete"}}}'`+"\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 != "legacy alive" {
t.Fatalf("expected legacy output, got %q", result.Output)
}
}
func TestCodexExecuteSemanticInactivityAllowsContinuousMessages(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
@@ -1156,7 +1318,6 @@ func TestCodexExecuteSemanticInactivityAllowsContinuousDeltaProgress(t *testing.
`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"+
@@ -1209,7 +1370,9 @@ func TestCodexExecuteSemanticInactivityDoesNotAffectNormalTurnCompletion(t *test
func writeFakeCodexAppServer(t *testing.T, body string) string {
t.Helper()
fakePath := filepath.Join(t.TempDir(), "codex")
script := "#!/bin/sh\n" + body
script := "#!/bin/sh\n" +
`if [ "$1" = "--version" ]; then echo "codex-cli 0.0.0-test"; exit 0; fi` + "\n" +
body
writeTestExecutable(t, fakePath, []byte(script))
return fakePath
}

Some files were not shown because too many files have changed in this diff Show More