diff --git a/apps/docs/content/docs/cli.ja.mdx b/apps/docs/content/docs/cli.ja.mdx index 3fc0fd3838a..599538c4cab 100644 --- a/apps/docs/content/docs/cli.ja.mdx +++ b/apps/docs/content/docs/cli.ja.mdx @@ -250,7 +250,7 @@ CLI の設定ファイルには、あなたとして Multica にアクセスで | | `status ` | プロジェクトのステータスを変更 | | | | `resource list/add/update/remove` | プロジェクトの追加リソースを管理 | `--type`、`--url`、`--local-path`、`--daemon-id`、`--execution-mode`(ローカルディレクトリの `in_place` / `worktree`) | | `label` | `list/get/create/update/delete` | ワークスペースのラベルを管理 | `list`: `--resource-type`(`issue` または `skill`)、`--full-id`;`create`: `--name`、`--color`、`--resource-type`(`issue` または `skill`)、`--description` | -| `property` | `list/get/create/update/archive/unarchive` | ワークスペースのカスタムプロパティを管理 | `create`: `--name`、`--type`(`text`、`number`、`select`、`multi_select`、`date`、`checkbox`、`url`、`actor`、`multi_actor`)、`--option`(繰り返し可、select 系のみ);`list`: `--include-archived`;タイプは作成後に変更できません | +| `property` | `list/get/create/update/archive/unarchive` | ワークスペースのカスタムプロパティを管理 | `create`: `--name`、`--type`(`text`、`number`、`select`、`multi_select`、`date`、`checkbox`、`url`、`actor`、`multi_actor`、`multi_text`、`multi_url`)、`--option`(繰り返し可、select 系のみ);`list`: `--include-archived`;タイプは作成後に変更できません | | `agent` | `list/get/create/update/archive/restore` | エージェントを管理 | `--name`、`--runtime-id`(`create` では必須)、`--instructions`、`--conversation-starters`、`--model`、`--thinking-level`、`--mcp-config`、`--permission-mode`、`--max-concurrent-tasks` | | | `copy ` | 新しいエージェントとして複製。元のエージェントは変更されません | `--name`(デフォルトは元の名前 + ` (copy)`)、`--runtime-id`(別のランタイムへ複製する場合は `--model` の同時指定が必須)、`--no-skills`;`custom_env`、`mcp_config`、`runtime_config` などの機密設定は複製されないため、`create` と同じフラグで再指定してください | | | `tasks ` | エージェントの実行を表示 | | diff --git a/apps/docs/content/docs/cli.ko.mdx b/apps/docs/content/docs/cli.ko.mdx index dee41d0fe06..872d47884b3 100644 --- a/apps/docs/content/docs/cli.ko.mdx +++ b/apps/docs/content/docs/cli.ko.mdx @@ -250,7 +250,7 @@ CLI 설정 파일에는 사용자를 대신해 Multica에 접근할 수 있는 t | | `status ` | 프로젝트 상태 변경 | | | | `resource list/add/update/remove` | 프로젝트 추가 리소스 관리 | `--type`, `--url`, `--local-path`, `--daemon-id`, `--execution-mode`(로컬 디렉터리의 `in_place` / `worktree`) | | `label` | `list/get/create/update/delete` | 워크스페이스 라벨 관리 | `list`: `--resource-type`(`issue` 또는 `skill`), `--full-id`; `create`: `--name`, `--color`, `--resource-type`(`issue` 또는 `skill`), `--description` | -| `property` | `list/get/create/update/archive/unarchive` | 워크스페이스 사용자 지정 속성 관리 | `create`: `--name`, `--type`(`text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `actor`, `multi_actor`), `--option`(반복 가능, select 유형만). `list`: `--include-archived`. 유형은 생성 후 변경 불가 | +| `property` | `list/get/create/update/archive/unarchive` | 워크스페이스 사용자 지정 속성 관리 | `create`: `--name`, `--type`(`text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `actor`, `multi_actor`, `multi_text`, `multi_url`), `--option`(반복 가능, select 유형만). `list`: `--include-archived`. 유형은 생성 후 변경 불가 | | `agent` | `list/get/create/update/archive/restore` | 에이전트 관리 | `--name`, `--runtime-id`(`create`에서 필수), `--instructions`, `--conversation-starters`, `--model`, `--thinking-level`, `--mcp-config`, `--permission-mode`, `--max-concurrent-tasks` | | | `copy ` | 원래 에이전트에 영향 없이 새 에이전트로 복사 | `--name`(기본값은 원래 이름 + ` (copy)`), `--runtime-id`(다른 런타임으로 복사할 때 `--model`도 필수), `--no-skills`. `custom_env`, `mcp_config`, `runtime_config` 같은 기밀 설정은 복사되지 않으며 `create`와 같은 flag로 다시 제공해야 함 | | | `tasks ` | 에이전트 실행 확인 | | diff --git a/apps/docs/content/docs/cli.mdx b/apps/docs/content/docs/cli.mdx index fae681ab278..a5af8453374 100644 --- a/apps/docs/content/docs/cli.mdx +++ b/apps/docs/content/docs/cli.mdx @@ -273,7 +273,7 @@ The tables below cover every current top-level command, grouped the way the CLI | | `status ` | Change project status | | | | `resource list/add/update/remove` | Manage project resources | `--type`, `--url`, `--local-path`, `--daemon-id`, `--execution-mode` (`in_place` / `worktree` for a local directory) | | `label` | `list/get/create/update/delete` | Manage workspace labels | `list`: `--resource-type` (`issue` or `skill`), `--full-id`; `create`: `--name`, `--color`, `--resource-type` (`issue` or `skill`), `--description` | -| `property` | `list/get/create/update/archive/unarchive` | Manage workspace custom properties | `create`: `--name`, `--type` (`text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `actor`, `multi_actor`), `--option` (repeatable, select types only); `list`: `--include-archived`; the type cannot be changed after creation | +| `property` | `list/get/create/update/archive/unarchive` | Manage workspace custom properties | `create`: `--name`, `--type` (`text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `actor`, `multi_actor`, `multi_text`, `multi_url`), `--option` (repeatable, select types only); `list`: `--include-archived`; the type cannot be changed after creation | | `agent` | `list/get/create/update/archive/restore` | Manage agents | `--name`, `--runtime-id` (required for `create`), `--instructions`, `--conversation-starters`, `--model`, `--thinking-level`, `--mcp-config`, `--permission-mode`, `--max-concurrent-tasks` | | | `copy ` | Copy into a new agent; the original is untouched | `--name` (defaults to the original name plus ` (copy)`), `--runtime-id` (copying to another runtime also requires `--model`), `--no-skills`; secret configuration such as `custom_env`, `mcp_config`, and `runtime_config` is not copied — re-provide it with the same flags as `create` | | | `tasks ` | View an agent's runs | | diff --git a/apps/docs/content/docs/cli.zh.mdx b/apps/docs/content/docs/cli.zh.mdx index 65eb81c567d..0aedabad6f7 100644 --- a/apps/docs/content/docs/cli.zh.mdx +++ b/apps/docs/content/docs/cli.zh.mdx @@ -250,7 +250,7 @@ CLI 配置文件包含可代表你访问 Multica 的令牌。不要提交到仓 | | `status ` | 修改项目状态 | | | | `resource list/add/update/remove` | 管理项目附加资源 | `--type`、`--url`、`--local-path`、`--daemon-id`、`--execution-mode`(本地目录的 `in_place` / `worktree`) | | `label` | `list/get/create/update/delete` | 管理工作区标签 | `list`:`--resource-type`(`issue` 或 `skill`)、`--full-id`;`create`:`--name`、`--color`、`--resource-type`(`issue` 或 `skill`)、`--description` | -| `property` | `list/get/create/update/archive/unarchive` | 管理工作区自定义属性 | `create`:`--name`、`--type`(`text`、`number`、`select`、`multi_select`、`date`、`checkbox`、`url`、`actor`、`multi_actor`)、`--option`(可重复,仅 select 类型);`list`:`--include-archived`;类型创建后不可修改 | +| `property` | `list/get/create/update/archive/unarchive` | 管理工作区自定义属性 | `create`:`--name`、`--type`(`text`、`number`、`select`、`multi_select`、`date`、`checkbox`、`url`、`actor`、`multi_actor`、`multi_text`、`multi_url`)、`--option`(可重复,仅 select 类型);`list`:`--include-archived`;类型创建后不可修改 | | `agent` | `list/get/create/update/archive/restore` | 管理智能体 | `--name`、`--runtime-id`(`create` 必填)、`--instructions`、`--conversation-starters`、`--model`、`--thinking-level`、`--mcp-config`、`--permission-mode`、`--max-concurrent-tasks` | | | `copy ` | 复制为新智能体,原智能体不受影响 | `--name`(默认为原名加 ` (copy)`)、`--runtime-id`(复制到其他运行时时必须同时指定 `--model`)、`--no-skills`;`custom_env`、`mcp_config`、`runtime_config` 等机密配置不会复制,需用与 `create` 相同的 flag 重新提供 | | | `tasks ` | 查看智能体的运行 | | diff --git a/apps/docs/content/docs/self-host-quickstart.fr.mdx b/apps/docs/content/docs/self-host-quickstart.fr.mdx index 1c256542e3e..6de98c74c95 100644 --- a/apps/docs/content/docs/self-host-quickstart.fr.mdx +++ b/apps/docs/content/docs/self-host-quickstart.fr.mdx @@ -315,7 +315,7 @@ docker compose -f docker-compose.selfhost.yml up -d La version que vous exécutez au final est déterminée par `docker compose pull`, qui demande à GHCR vers quoi pointe le tag à cet instant. Une copie locale en retard de plusieurs mois téléchargera quand même les images `latest` du jour ; à l'inverse, `git pull` seul ne change rien tant que vous n'avez pas téléchargé les images et recréé les conteneurs. -**Si vous avez figé `MULTICA_IMAGE_TAG`, aucune des deux commandes ne met quoi que ce soit à niveau.** Les deux images sont résolues en `${MULTICA_IMAGE_TAG:-latest}` (`docker-compose.selfhost.yml:42`, `:125`), et `.env.example` fournit `MULTICA_IMAGE_TAG=latest`. Si votre `.env` fige une version précise, `pull` récupère simplement de nouveau ce même tag et vous restez sur l'ancienne version — sans erreur ni avertissement. Vérifiez avant de mettre à niveau : +**Si vous avez figé `MULTICA_IMAGE_TAG`, aucune des deux commandes ne met quoi que ce soit à niveau.** Les deux images sont résolues en `${MULTICA_IMAGE_TAG:-latest}` (`docker-compose.selfhost.yml`, `services.backend.image` / `services.frontend.image`), et `.env.example` fournit `MULTICA_IMAGE_TAG=latest`. Si votre `.env` fige une version précise, `pull` récupère simplement de nouveau ce même tag et vous restez sur l'ancienne version — sans erreur ni avertissement. Vérifiez avant de mettre à niveau : ```bash grep MULTICA_IMAGE_TAG .env @@ -354,7 +354,7 @@ Les migrations s'exécutent automatiquement au démarrage du backend ; celles qu ### Vérifier avec `/readyz`, pas `/health` -`/health` est une sonde de **vivacité** (liveness) — elle renvoie `{"status":"ok"}` tant que le processus tourne, y compris lorsque les migrations ont échoué. `/readyz` (`server/cmd/server/router.go:680` ; `/healthz` en est un alias) vérifie la base de données et l'ensemble des migrations appliquées : c'est donc elle qui détecte une mise à niveau ratée : +`/health` est une sonde de **vivacité** (liveness) — elle renvoie `{"status":"ok"}` tant que le processus tourne, y compris lorsque les migrations ont échoué. `/readyz` (`readyHandler`, enregistré dans `server/cmd/server/router.go` ; `/healthz` en est un alias) vérifie la base de données et l'ensemble des migrations appliquées : c'est donc elle qui détecte une mise à niveau ratée : ```bash curl -s localhost:8080/readyz diff --git a/apps/docs/content/docs/self-host-quickstart.ja.mdx b/apps/docs/content/docs/self-host-quickstart.ja.mdx index 9ff5cc4bd5d..32e5e0872fc 100644 --- a/apps/docs/content/docs/self-host-quickstart.ja.mdx +++ b/apps/docs/content/docs/self-host-quickstart.ja.mdx @@ -314,7 +314,7 @@ docker compose -f docker-compose.selfhost.yml up -d 実際に動くバージョンを決めるのは `docker compose pull` で、そのタグが今どのイメージを指しているかを GHCR に問い合わせます。したがって数か月更新していない checkout でも今日の `latest` イメージを取得できますし、逆に `git pull` だけではイメージを取得してコンテナを作り直すまで何も変わりません。 -**`MULTICA_IMAGE_TAG` を固定している場合、どちらのコマンドでもアップグレードされません。** 両方のイメージが `${MULTICA_IMAGE_TAG:-latest}` として解決され(`docker-compose.selfhost.yml:42`、`:125`)、`.env.example` は `MULTICA_IMAGE_TAG=latest` を同梱しています。`.env` で特定のリリースに固定していると、`pull` は同じタグを取り直すだけで、古いバージョンのままです — エラーも警告も出ません。アップグレード前に確認してください。 +**`MULTICA_IMAGE_TAG` を固定している場合、どちらのコマンドでもアップグレードされません。** 両方のイメージが `${MULTICA_IMAGE_TAG:-latest}` として解決され(`docker-compose.selfhost.yml`:`services.backend.image`、`services.frontend.image`)、`.env.example` は `MULTICA_IMAGE_TAG=latest` を同梱しています。`.env` で特定のリリースに固定していると、`pull` は同じタグを取り直すだけで、古いバージョンのままです — エラーも警告も出ません。アップグレード前に確認してください。 ```bash grep MULTICA_IMAGE_TAG .env @@ -353,7 +353,7 @@ migration は backend の起動時に自動で実行されます。過去デー ### `/health` ではなく `/readyz` で検証する -`/health` は **liveness** プローブで、プロセスが生きている限り `{"status":"ok"}` を返します — migration が失敗していても同じです。`/readyz`(`server/cmd/server/router.go:680`、`/healthz` はそのエイリアス)はデータベースと適用済み migration の集合をチェックするため、失敗したアップグレードを捕捉できるのはこちらです。 +`/health` は **liveness** プローブで、プロセスが生きている限り `{"status":"ok"}` を返します — migration が失敗していても同じです。`/readyz`(`server/cmd/server/router.go` で登録された `readyHandler`、`/healthz` はそのエイリアス)はデータベースと適用済み migration の集合をチェックするため、失敗したアップグレードを捕捉できるのはこちらです。 ```bash curl -s localhost:8080/readyz diff --git a/apps/docs/content/docs/self-host-quickstart.ko.mdx b/apps/docs/content/docs/self-host-quickstart.ko.mdx index 0e36f9fd745..1f7950dcdc1 100644 --- a/apps/docs/content/docs/self-host-quickstart.ko.mdx +++ b/apps/docs/content/docs/self-host-quickstart.ko.mdx @@ -314,7 +314,7 @@ docker compose -f docker-compose.selfhost.yml up -d 실제로 어떤 버전이 뜰지는 `docker compose pull`이 결정합니다. 이 명령이 GHCR에 해당 태그가 지금 어떤 이미지를 가리키는지 물어보기 때문입니다. 그래서 몇 달 묵은 checkout에서도 오늘자 `latest` 이미지를 받을 수 있고, 반대로 `git pull`만 해서는 이미지를 받아 컨테이너를 다시 만들기 전까지 아무것도 바뀌지 않습니다. -**`MULTICA_IMAGE_TAG`를 고정해 뒀다면 두 방법 모두 업그레이드되지 않습니다.** 두 이미지 모두 `${MULTICA_IMAGE_TAG:-latest}`로 해석되며(`docker-compose.selfhost.yml:42`, `:125`), `.env.example`에는 `MULTICA_IMAGE_TAG=latest`가 들어 있습니다. `.env`에서 특정 릴리스로 고정해 뒀다면 `pull`은 같은 태그를 다시 받아올 뿐이라 예전 버전에 그대로 머무릅니다 — 오류도, 경고도 없습니다. 업그레이드 전에 확인하세요. +**`MULTICA_IMAGE_TAG`를 고정해 뒀다면 두 방법 모두 업그레이드되지 않습니다.** 두 이미지 모두 `${MULTICA_IMAGE_TAG:-latest}`로 해석되며(`docker-compose.selfhost.yml`, `services.backend.image` / `services.frontend.image`), `.env.example`에는 `MULTICA_IMAGE_TAG=latest`가 들어 있습니다. `.env`에서 특정 릴리스로 고정해 뒀다면 `pull`은 같은 태그를 다시 받아올 뿐이라 예전 버전에 그대로 머무릅니다 — 오류도, 경고도 없습니다. 업그레이드 전에 확인하세요. ```bash grep MULTICA_IMAGE_TAG .env @@ -353,7 +353,7 @@ Migration은 backend 시작 시 자동으로 실행됩니다. migration 103처 ### `/health` 말고 `/readyz`로 검증하세요 -`/health`는 **liveness** 프로브라서 프로세스가 살아 있기만 하면 `{"status":"ok"}`를 반환합니다 — migration이 실패했더라도 마찬가지입니다. `/readyz`(`server/cmd/server/router.go:680`, `/healthz`는 별칭)는 데이터베이스와 적용된 migration 집합을 확인하므로, 잘못된 업그레이드를 잡아내는 것은 이쪽입니다. +`/health`는 **liveness** 프로브라서 프로세스가 살아 있기만 하면 `{"status":"ok"}`를 반환합니다 — migration이 실패했더라도 마찬가지입니다. `/readyz`(`server/cmd/server/router.go`에 등록된 `readyHandler`, `/healthz`는 별칭)는 데이터베이스와 적용된 migration 집합을 확인하므로, 잘못된 업그레이드를 잡아내는 것은 이쪽입니다. ```bash curl -s localhost:8080/readyz diff --git a/apps/docs/content/docs/self-host-quickstart.mdx b/apps/docs/content/docs/self-host-quickstart.mdx index 0e4bcc38bab..136d5326986 100644 --- a/apps/docs/content/docs/self-host-quickstart.mdx +++ b/apps/docs/content/docs/self-host-quickstart.mdx @@ -315,7 +315,7 @@ docker compose -f docker-compose.selfhost.yml up -d The version you end up running is resolved by `docker compose pull`, which asks GHCR what the tag points at right now. A checkout that is months out of date will still pull today's `latest` images; conversely, `git pull` alone changes nothing until you pull images and recreate the containers. -**If you pinned `MULTICA_IMAGE_TAG`, neither command upgrades anything.** Both images resolve as `${MULTICA_IMAGE_TAG:-latest}` (`docker-compose.selfhost.yml:42`, `:125`), and `.env.example` ships `MULTICA_IMAGE_TAG=latest`. If your `.env` pins an exact release, `pull` just re-fetches that same tag and you stay on the old version — no error, no warning. Check before you upgrade: +**If you pinned `MULTICA_IMAGE_TAG`, neither command upgrades anything.** Both images resolve as `${MULTICA_IMAGE_TAG:-latest}` (`docker-compose.selfhost.yml`, `services.backend.image` / `services.frontend.image`), and `.env.example` ships `MULTICA_IMAGE_TAG=latest`. If your `.env` pins an exact release, `pull` just re-fetches that same tag and you stay on the old version — no error, no warning. Check before you upgrade: ```bash grep MULTICA_IMAGE_TAG .env @@ -354,7 +354,7 @@ Migrations run automatically when the backend starts; migrations that backfill h ### Verify with `/readyz`, not `/health` -`/health` is a **liveness** probe — it returns `{"status":"ok"}` as long as the process is up, including when migrations failed. `/readyz` (`server/cmd/server/router.go:680`; `/healthz` is an alias) checks the database and the applied migration set, so it is the one that catches a bad upgrade: +`/health` is a **liveness** probe — it returns `{"status":"ok"}` as long as the process is up, including when migrations failed. `/readyz` (`readyHandler`, registered in `server/cmd/server/router.go`; `/healthz` is an alias) checks the database and the applied migration set, so it is the one that catches a bad upgrade: ```bash curl -s localhost:8080/readyz diff --git a/apps/docs/content/docs/self-host-quickstart.zh.mdx b/apps/docs/content/docs/self-host-quickstart.zh.mdx index 1703e15bec2..64830e92faa 100644 --- a/apps/docs/content/docs/self-host-quickstart.zh.mdx +++ b/apps/docs/content/docs/self-host-quickstart.zh.mdx @@ -314,7 +314,7 @@ docker compose -f docker-compose.selfhost.yml up -d 真正决定你跑哪个版本的是 `docker compose pull`:它去 GHCR 查这个 tag 当前指向哪个镜像。所以一个几个月没更新的 checkout 照样能拉到今天的 `latest` 镜像;反过来,只跑 `git pull` 而不拉镜像、不重建容器,什么都不会变。 -**如果你在 `.env` 里钉死了 `MULTICA_IMAGE_TAG`,两种写法都升不上去。** 两个镜像都写成 `${MULTICA_IMAGE_TAG:-latest}`(`docker-compose.selfhost.yml:42`、`:125`),而 `.env.example` 里默认是 `MULTICA_IMAGE_TAG=latest`。一旦你把它改成了具体版本,`pull` 只是把同一个 tag 重拉一遍,版本原地不动——不报错,也没有任何警告。升级前先确认: +**如果你在 `.env` 里钉死了 `MULTICA_IMAGE_TAG`,两种写法都升不上去。** 两个镜像都写成 `${MULTICA_IMAGE_TAG:-latest}`(`docker-compose.selfhost.yml`:`services.backend.image`、`services.frontend.image`),而 `.env.example` 里默认是 `MULTICA_IMAGE_TAG=latest`。一旦你把它改成了具体版本,`pull` 只是把同一个 tag 重拉一遍,版本原地不动——不报错,也没有任何警告。升级前先确认: ```bash grep MULTICA_IMAGE_TAG .env @@ -353,7 +353,7 @@ Migration 在 backend 启动时自动运行;需要回填历史数据的 migrat ### 用 `/readyz` 验证,不要用 `/health` -`/health` 是 **liveness** 探针,只要进程还活着就返回 `{"status":"ok"}`,migration 失败了它照样是 ok。`/readyz`(`server/cmd/server/router.go:680`,`/healthz` 是它的别名)会真正检查数据库和已应用的 migration 集合,升级出问题能被它拦下来: +`/health` 是 **liveness** 探针,只要进程还活着就返回 `{"status":"ok"}`,migration 失败了它照样是 ok。`/readyz`(`server/cmd/server/router.go` 中注册的 `readyHandler`,`/healthz` 是它的别名)会真正检查数据库和已应用的 migration 集合,升级出问题能被它拦下来: ```bash curl -s localhost:8080/readyz diff --git a/packages/core/issues/queries.test.ts b/packages/core/issues/queries.test.ts index 92470aa575d..1f7ef40c10b 100644 --- a/packages/core/issues/queries.test.ts +++ b/packages/core/issues/queries.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { setApiInstance } from "../api"; @@ -8,21 +8,27 @@ import type { Issue, IssueTableRowsRequest, IssueTableRowsResponse, + ListIssuesCache, ListIssuesParams, ListIssuesResponse, } from "../types"; +import { pruneDeletedIssueFromListCaches } from "./delete-cache"; import { CHILDREN_BY_PARENTS_CHUNK_SIZE, + ISSUE_PAGE_SIZE, PROJECT_GANTT_MAX_ISSUES, PROJECT_GANTT_PAGE_LIMIT, childrenByParentsOptions, childIssuesOptions, + flattenIssueBuckets, issueIdentifierOptions, issueKeys, + issueListOptions, issueTableRowPageOptions, projectGanttIssuesOptions, sourceContextPreviewOptions, } from "./queries"; +import { onIssueUpdated } from "./ws-updaters"; const WS_ID = "ws-1"; const PROJECT_ID = "project-1"; @@ -315,6 +321,84 @@ describe("issueTableRowPageOptions", () => { }); }); +describe("issueListOptions", () => { + const todoIssue = makeIssue(1, { status: "todo", status_category: "unstarted" }); + const startedIssue = makeIssue(2, { status: "in_progress", status_category: "started" }); + const doneIssue = makeIssue(3, { status: "done", status_category: "done" }); + // A custom status the server did not resolve to a category. + const customIssue = makeIssue(4, { status: "qa" }); + + let qc: QueryClient; + let listIssues: Mock<(params?: ListIssuesParams) => Promise>; + + beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + // Like the real client, the fake ignores `status_category`: every call + // returns the same first page. + listIssues = vi.fn<(params?: ListIssuesParams) => Promise>().mockResolvedValue({ + issues: [todoIssue, startedIssue, doneIssue, customIssue], + total: 120, + }); + installFakeApi(listIssues); + }); + + afterEach(() => { + qc.clear(); + }); + + async function fetchList() { + const options = issueListOptions(WS_ID); + await qc.fetchQuery(options); + return () => qc.getQueryData(options.queryKey)!; + } + + it("sends one list request per fetch", async () => { + await fetchList(); + + expect(listIssues).toHaveBeenCalledTimes(1); + expect(listIssues).toHaveBeenCalledWith({ limit: ISSUE_PAGE_SIZE, offset: 0 }); + }); + + it("puts each issue in its own category's bucket only", async () => { + const cache = (await fetchList())(); + + expect(cache.byStatus).toEqual({ + unstarted: { issues: [todoIssue, customIssue], total: 2 }, + started: { issues: [startedIssue], total: 1 }, + done: { issues: [doneIssue], total: 1 }, + closed: { issues: [], total: 0 }, + }); + expect(flattenIssueBuckets(cache).map((issue) => issue.id)).toEqual([ + todoIssue.id, + customIssue.id, + startedIssue.id, + doneIssue.id, + ]); + }); + + it("drops a deleted issue from every bucket", async () => { + const readCache = await fetchList(); + + pruneDeletedIssueFromListCaches(qc, WS_ID, todoIssue.id); + + expect(flattenIssueBuckets(readCache()).map((issue) => issue.id)).not.toContain(todoIssue.id); + }); + + it("keeps only the updated copy of an issue moved to done", async () => { + const readCache = await fetchList(); + + onIssueUpdated( + qc, + WS_ID, + { id: todoIssue.id, status: "done", status_category: "done" }, + { statusChanged: true }, + ); + + const copies = flattenIssueBuckets(readCache()).filter((issue) => issue.id === todoIssue.id); + expect(copies).toEqual([{ ...todoIssue, status: "done", status_category: "done" }]); + }); +}); + describe("projectGanttIssuesOptions", () => { let qc: QueryClient; diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index b7c968abd21..af3665a8f27 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -19,6 +19,7 @@ import type { ListIssuesCache, } from "../types"; import { ALL_STATUSES } from "./config"; +import { issueColumnCategory } from "./status-category"; export function issueTasksOptions(issueId: string) { return queryOptions({ @@ -242,7 +243,7 @@ export type AssigneeGroupedIssuesFilter = Omit< "group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id" >; -/** Page size per status column. */ +/** Size of the issue list's first page, all categories together. */ export const ISSUE_PAGE_SIZE = 50; /** @@ -267,17 +268,20 @@ export function flattenIssueBuckets(data: ListIssuesCache) { return out; } +/** + * One request for the list's first page, grouped into the category buckets + * here. Every reader of this cache looks an issue up by id and none pages a + * bucket, so one shared window serves them all. Each bucket's `total` is its + * own row count. A custom status the server did not resolve falls back to + * `issueColumnCategory`'s bucket, so no row is dropped. + */ async function fetchFirstPages(filter: MyIssuesFilter = {}, sort?: IssueSortParam): Promise { - const responses = await Promise.all( - PAGINATED_CATEGORIES.map((category) => - api.listIssues({ status_category: category, limit: ISSUE_PAGE_SIZE, offset: 0, ...sort, ...filter }), - ), - ); + const res = await api.listIssues({ limit: ISSUE_PAGE_SIZE, offset: 0, ...sort, ...filter }); const byStatus: ListIssuesCache["byStatus"] = {}; - PAGINATED_CATEGORIES.forEach((status: IssueStatusCategory, i: number) => { - const res = responses[i]!; - byStatus[status] = { issues: res.issues, total: res.total }; - }); + for (const category of PAGINATED_CATEGORIES) { + const issues = res.issues.filter((issue) => issueColumnCategory(issue) === category); + byStatus[category] = { issues, total: issues.length }; + } return { byStatus }; } @@ -361,7 +365,7 @@ export function issueTableFacetsOptions( * `Issue[]` for consumers. Mutations and ws-updaters must use * `setQueryData(...)` and preserve the byStatus shape. * - * Fetches the first page of each paginated status in parallel. + * Fetches the first page in one request and buckets it by category. */ export function issueListOptions(wsId: string, sort?: IssueSortParam) { return queryOptions({ diff --git a/packages/core/search-index/engine.test.ts b/packages/core/search-index/engine.test.ts index d793298e000..e9894708416 100644 --- a/packages/core/search-index/engine.test.ts +++ b/packages/core/search-index/engine.test.ts @@ -144,18 +144,27 @@ describe("SearchIndexEngine.searchIssues ranking", () => { ]); }); - it("puts an exact identifier or bare number first, even without a text match", () => { - const target = issue({ title: "no text overlap", number: 4242, identifier: "MUL-4242" }); + it.each(["MUL", "V2", "A1"])("puts a %s identifier or bare number first, even without a text match", (prefix) => { + const target = issue({ title: "no text overlap", number: 4242, identifier: `${prefix}-4242` }); const textHit = issue({ title: "mentions 4242 in the title" }); const engine = engineWith([textHit, target]); - const byIdentifier = engine.searchIssues({ q: "mul-4242" }); + const byIdentifier = engine.searchIssues({ q: ` ${prefix.toLowerCase()}-4242 ` }); expect(byIdentifier.map((h) => h.id)).toEqual([target.id]); expect(byIdentifier[0]!.matchSource).toBe("comment"); expect(engine.searchIssues({ q: "4242" }).map((h) => h.id)).toEqual([target.id, textHit.id]); }); + it("keeps a cancelled digit-prefix identifier ahead of text hits before limiting", () => { + const target = issue({ title: "no text overlap", number: 12, identifier: "V2-12", status: "cancelled" }); + const textHit = issue({ title: "notes about V2-12" }); + const engine = engineWith([textHit, target]); + + expect(engine.searchIssues({ q: "V2-12", include_closed: true, limit: 1 }).map((h) => h.id)).toEqual([target.id]); + expect(engine.searchIssues({ q: "V2-12", limit: 1 }).map((h) => h.id)).toEqual([textHit.id]); + }); + it("demotes cancelled issues unless the query targets them directly", () => { const cancelled = issue({ title: "billing export", status: "cancelled" }); const live = issue({ title: "old billing export job", status: "done" }); diff --git a/packages/core/search-index/engine.ts b/packages/core/search-index/engine.ts index 865a5f39487..5aedf5b143b 100644 --- a/packages/core/search-index/engine.ts +++ b/packages/core/search-index/engine.ts @@ -140,10 +140,10 @@ function isTerminal(issue: Pick) return category === "done" || category === "closed"; } -/** Server parseQueryNumber: "MUL-123" or a bare "123". */ +/** Server parseQueryNumber: "MUL-123", "V2-12", or a bare "123". */ function parseQueryNumber(query: string): number | null { const q = query.trim(); - const match = /^[a-z]+-(\d+)$/i.exec(q) ?? /^(\d+)$/.exec(q); + const match = /^[a-z][a-z0-9]*-(\d+)$/i.exec(q) ?? /^(\d+)$/.exec(q); if (!match) return null; const n = Number.parseInt(match[1]!, 10); return Number.isInteger(n) && n > 0 ? n : null; diff --git a/packages/core/search/cancelled-rank.test.ts b/packages/core/search/cancelled-rank.test.ts index f80d79c64cf..26fde8e5a6b 100644 --- a/packages/core/search/cancelled-rank.test.ts +++ b/packages/core/search/cancelled-rank.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { describe, it, expect } from "vitest"; import type { SearchIssueResult, SearchProjectResult } from "../types/api"; import { @@ -39,6 +40,20 @@ describe("parseSearchQueryNumber", () => { expect(parseSearchQueryNumber(" 123 ")).toBe(123); }); + it.each([ + ["V2-12", 12], + [" v2-12 ", 12], + ["A1-7", 7], + ["a1b2-42", 42], + ])("reads a digit-containing prefix in %s", (query, number) => { + expect(parseSearchQueryNumber(query)).toBe(number); + }); + + it.each(["12-3", "2FAS-1", "V2-0", "V2--12", "V2-12x", "V_2-12"])( + "does not read %s as an issue number", + (query) => expect(parseSearchQueryNumber(query)).toBeNull(), + ); + it("returns null for anything that is not a target", () => { expect(parseSearchQueryNumber("search")).toBeNull(); expect(parseSearchQueryNumber("MUL-")).toBeNull(); diff --git a/packages/core/search/cancelled-rank.ts b/packages/core/search/cancelled-rank.ts index e5884e57958..4085202be51 100644 --- a/packages/core/search/cancelled-rank.ts +++ b/packages/core/search/cancelled-rank.ts @@ -24,9 +24,9 @@ import { issueBehavesAs } from "../issues/status-category"; /** * Mirrors the server's identifier pattern (parseQueryNumber in - * server/internal/handler/issue.go): "MUL-123" or a bare "123". + * server/internal/handler/issue.go): "MUL-123", "V2-12", or a bare "123". */ -const IDENTIFIER_NUMBER_RE = /^[a-z]+-(\d+)$/i; +const IDENTIFIER_NUMBER_RE = /^[a-z][a-z0-9]*-(\d+)$/i; /** Extracts the issue number a query targets, or null when it targets none. */ export function parseSearchQueryNumber(query: string): number | null { diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 8fb02b9f992..50b07951bd7 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -148,7 +148,8 @@ export interface ListIssuesParams { /** * Filter by lifecycle category rather than by exact key, so one bucket holds * all concrete and custom statuses in that phase. Task views use exact - * status keys for their columns instead. + * status keys for their columns instead. The web client's + * `ApiClient.listIssues` does not send it. */ status_category?: IssueStatusCategory; /** Multi-value form of `status_category`. OR within the field. */ @@ -512,16 +513,16 @@ export interface WorkingAgentSummary { running_task_count: number; } -/** Per-status bucket in the paginated issue cache. `total` is the server count (all pages), not the length of `issues`. */ +/** Per-category bucket in the issue list cache. The list's fetch sets `total` to the bucket's row count. */ export interface IssueStatusBucket { issues: Issue[]; total: number; } /** - * Frontend cache shape for the issue list. Data is bucketed by status so - * each column can paginate independently. Assembled from per-status - * `api.listIssues` responses by the query functions in `issues/queries.ts`. + * Frontend cache shape for the issue list. Data is bucketed by status + * category. Assembled from one `api.listIssues` response by the query + * functions in `issues/queries.ts`. */ export interface ListIssuesCache { /** Bucketed by status CATEGORY — see PAGINATED_CATEGORIES. (MUL-6243) */ diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 9e67ddad4f7..de65f707fc3 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -120,8 +120,8 @@ export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread, Arc export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference"; export type { Comment, CommentType, CommentAuthorType, CommentSupplementReceipt, CommentSupplementStatus, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, CommentTriggerOutcome, CommentTriggerStatus, Reaction } from "./comment"; export type { Label, LabelResourceType, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse, ResourceLabelsResponse } from "./label"; -export type { IssueProperty, IssuePropertyType, ScalarIssuePropertyType, IssuePropertyOption, IssuePropertyConfig, IssuePropertyValue, IssuePropertyValues, CreatePropertyRequest, UpdatePropertyRequest, ListPropertiesResponse, IssuePropertiesResponse, IssuePropertyActorKind, IssuePropertyActorRef, PropertyFilterOp, PropertyOperatorFilter, PropertyFilterValue } from "./property"; -export { ISSUE_PROPERTY_TYPES, isKnownPropertyType, ISSUE_PROPERTY_ACTOR_KINDS, MAX_ISSUE_PROPERTY_ACTOR_VALUES, isActorPropertyType, isFilterablePropertyType, isScalarPropertyType, formatActorRef, parseActorRef, actorRefsFromValue, actorRefValuesFromValue, hasUnknownActorRef, isPropertyOperatorFilter, isKnownPropertyFilterOp, propertyFilterValueKey, PROPERTY_FILTER_OP_SYMBOLS, PROPERTY_FILTER_OPS_BY_TYPE } from "./property"; +export type { IssueProperty, IssuePropertyType, ScalarIssuePropertyType, ListIssuePropertyType, IssuePropertyOption, IssuePropertyConfig, IssuePropertyValue, IssuePropertyValues, CreatePropertyRequest, UpdatePropertyRequest, ListPropertiesResponse, IssuePropertiesResponse, IssuePropertyActorKind, IssuePropertyActorRef, PropertyFilterOp, PropertyOperatorFilter, PropertyFilterValue } from "./property"; +export { ISSUE_PROPERTY_TYPES, isKnownPropertyType, ISSUE_PROPERTY_ACTOR_KINDS, MAX_ISSUE_PROPERTY_ACTOR_VALUES, isActorPropertyType, isFilterablePropertyType, isScalarPropertyType, isListPropertyType, formatActorRef, parseActorRef, actorRefsFromValue, actorRefValuesFromValue, hasUnknownActorRef, isPropertyOperatorFilter, isKnownPropertyFilterOp, propertyFilterValueKey, PROPERTY_FILTER_OP_SYMBOLS, PROPERTY_FILTER_OPS_BY_TYPE } from "./property"; export type { QuickAction, QuickActionVisibility, diff --git a/packages/core/types/property.test.ts b/packages/core/types/property.test.ts index 0e9789b643c..4c0e27643ed 100644 --- a/packages/core/types/property.test.ts +++ b/packages/core/types/property.test.ts @@ -8,6 +8,7 @@ import { isActorPropertyType, isFilterablePropertyType, isKnownPropertyType, + isListPropertyType, isScalarPropertyType, parseActorRef, } from "./property"; @@ -48,6 +49,8 @@ describe("isFilterablePropertyType", () => { "url", "actor", "multi_actor", + "multi_text", + "multi_url", ]) { expect(isFilterablePropertyType(type)).toBe(true); } @@ -69,6 +72,16 @@ describe("isScalarPropertyType", () => { }); }); +describe("isListPropertyType", () => { + it("covers both list types and nothing else", () => { + expect(isListPropertyType("multi_text")).toBe(true); + expect(isListPropertyType("multi_url")).toBe(true); + expect(isListPropertyType("text")).toBe(false); + expect(isListPropertyType("url")).toBe(false); + expect(isListPropertyType("multi_select")).toBe(false); + }); +}); + describe("parseActorRef", () => { it("parses member references", () => { expect(parseActorRef(`member:${MEMBER}`)).toEqual({ kind: "member", id: MEMBER }); diff --git a/packages/core/types/property.ts b/packages/core/types/property.ts index 7931706b090..22b7e7c4dd9 100644 --- a/packages/core/types/property.ts +++ b/packages/core/types/property.ts @@ -7,7 +7,8 @@ * Values are typed per definition: select stores an option id, multi_select * an array of option ids (config order), date a "YYYY-MM-DD" string, checkbox * a boolean, number a number, text/url strings, actor a "member:" - * reference string, multi_actor an array of them (insertion order). + * reference string, multi_actor an array of them (insertion order), and + * multi_text/multi_url arrays of free-form strings (insertion order). */ export type IssuePropertyType = | "text" @@ -18,7 +19,9 @@ export type IssuePropertyType = | "checkbox" | "url" | "actor" - | "multi_actor"; + | "multi_actor" + | "multi_text" + | "multi_url"; export const ISSUE_PROPERTY_TYPES: IssuePropertyType[] = [ "text", @@ -30,6 +33,8 @@ export const ISSUE_PROPERTY_TYPES: IssuePropertyType[] = [ "url", "actor", "multi_actor", + "multi_text", + "multi_url", ]; export function isKnownPropertyType(type: string): type is IssuePropertyType { @@ -74,7 +79,8 @@ export function isFilterablePropertyType(type: string): boolean { type === "multi_select" || type === "checkbox" || isScalarPropertyType(type) || - isActorPropertyType(type) + isActorPropertyType(type) || + isListPropertyType(type) ); } @@ -85,6 +91,16 @@ export function isScalarPropertyType(type: string): type is ScalarIssuePropertyT return type === "text" || type === "url" || type === "number" || type === "date"; } +/** + * Free-form list properties: multi_text / multi_url. Values are string arrays + * in insertion order; filtering matches any single element exactly. + */ +export type ListIssuePropertyType = Extract; + +export function isListPropertyType(type: string): type is ListIssuePropertyType { + return type === "multi_text" || type === "multi_url"; +} + export function formatActorRef(kind: IssuePropertyActorKind, id: string): string { return `${kind}:${id}`; } @@ -267,6 +283,8 @@ export const PROPERTY_FILTER_OPS_BY_TYPE: Record { expect(headings()).not.toContain("Cancelled"); }); - it("keeps a cancelled direct hit visible behind a full window of live candidates", async () => { + it.each([ + ["MUL-99", "MUL-99"], + ["V2-99", "v2-99"], + ["V2-99", "99"], + ])("keeps cancelled %s visible behind a full window when searching %s", async (identifier, query) => { // 20 non-cancelled cached candidates already fill every slot. Exempting // the direct hit from the demotion would leave it at position 21 and the // truncation would still delete it, so it has to be pinned to the front. searchIssuesMock.mockResolvedValue({ issues: [ - { id: "i-hit", identifier: "MUL-99", title: "Abandoned plan", status: "cancelled" }, + { id: "i-hit", identifier, title: "Abandoned plan", status: "cancelled" }, ], total: 1, }); @@ -1322,15 +1326,15 @@ describe("MentionList cancelled demotion", () => { status: "todo" as const, })); - render(); + render(); await waitFor(() => { - expect(screen.getByText("MUL-99")).toBeInTheDocument(); + expect(screen.getByText(identifier)).toBeInTheDocument(); }); const labels = rowLabels(); expect(labels).toHaveLength(20); - expect(labels[0]).toBe("MUL-99"); + expect(labels[0]).toBe(identifier); // A live candidate gave up the last slot, not the record the user typed. expect(labels).not.toContain("MUL-219"); }); diff --git a/packages/views/issues/components/filter-chips-bar.tsx b/packages/views/issues/components/filter-chips-bar.tsx index 191f6f10f9d..a396efd944d 100644 --- a/packages/views/issues/components/filter-chips-bar.tsx +++ b/packages/views/issues/components/filter-chips-bar.tsx @@ -23,7 +23,7 @@ import { projectListOptions } from "@multica/core/projects/queries"; import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config"; import { labelListOptions } from "@multica/core/labels/queries"; import { propertyListOptions } from "@multica/core/properties"; -import { isActorPropertyType, isScalarPropertyType, parseActorRef, propertyFilterValueKey, PROPERTY_FILTER_OP_SYMBOLS, type PropertyFilterValue } from "@multica/core/types"; +import { isActorPropertyType, isListPropertyType, isScalarPropertyType, parseActorRef, propertyFilterValueKey, PROPERTY_FILTER_OP_SYMBOLS, type PropertyFilterValue } from "@multica/core/types"; import { type ActorFilterValue, type FilterDimension, @@ -531,8 +531,9 @@ function useFilterChips( ? t(($) => $.pickers.custom_property.true_label) : t(($) => $.pickers.custom_property.false_label); } - // Scalar properties have no option list — the filter value IS the label. - if (isScalarPropertyType(definition.type)) { + // Scalar and list properties have no option list — the filter value IS + // the label (for lists it is one matched element). + if (isScalarPropertyType(definition.type) || isListPropertyType(definition.type)) { return member; } return definition.config.options?.find((o) => o.id === member)?.name; diff --git a/packages/views/issues/components/issue-chip.tsx b/packages/views/issues/components/issue-chip.tsx index 0427e71b77a..9b808778b6d 100644 --- a/packages/views/issues/components/issue-chip.tsx +++ b/packages/views/issues/components/issue-chip.tsx @@ -62,7 +62,8 @@ export function IssueChip({ const { data: issues = [] } = useQuery(issueListOptions(wsId)); const listIssue = issues.find((i) => i.id === issueId); - // Fallback fetch for issues outside the first page of the list (e.g. Done). + // Fallback fetch for issues outside the list's first page (the first + // ISSUE_PAGE_SIZE issues across all categories). const { data: detailIssue } = useQuery({ ...issueDetailOptions(wsId, issueId), enabled: !listIssue, diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index c080ac5cf18..3da45a9ebc6 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -76,7 +76,7 @@ import type { ProjectStatus, WorkingAgentSummary, } from "@multica/core/types"; -import { formatActorRef, isActorPropertyType, isFilterablePropertyType, isScalarPropertyType, propertyFilterValueKey, PROPERTY_FILTER_OP_SYMBOLS, PROPERTY_FILTER_OPS_BY_TYPE, type PropertyFilterOp, type PropertyFilterValue } from "@multica/core/types"; +import { formatActorRef, isActorPropertyType, isFilterablePropertyType, isListPropertyType, isScalarPropertyType, propertyFilterValueKey, PROPERTY_FILTER_OP_SYMBOLS, PROPERTY_FILTER_OPS_BY_TYPE, type PropertyFilterOp, type PropertyFilterValue } from "@multica/core/types"; import { ProjectIcon } from "../../projects/components/project-icon"; import { useProjectStatusLabels } from "../../projects/components/labels"; import { ActorAvatar } from "../../common/actor-avatar"; @@ -785,10 +785,11 @@ function PropertyFilterOptions({ actorId: undefined as string | undefined, }; // Scalar value state lives at the top level so the hooks stay unconditional - // (Rules of Hooks): it is only rendered for text / number / date / url, but - // must be declared regardless of which branch runs. The draft syncs to the - // committed scalar member whenever that changes, so a filter cleared or - // rewritten elsewhere cannot be written back from a stale input. + // (Rules of Hooks): it is only rendered for text / number / date / url and + // the list types (equality-only input), but must be declared regardless of + // which branch runs. The draft syncs to the committed scalar member whenever + // that changes, so a filter cleared or rewritten elsewhere cannot be written + // back from a stale input. const committedMember = selected.find((member) => member !== NO_PROPERTY_VALUE); const committedScalar = typeof committedMember === "object" ? committedMember.value : (committedMember ?? ""); @@ -833,9 +834,9 @@ function PropertyFilterOptions({ noValueOption, ]; - if (isScalarPropertyType(property.type)) { + if (isScalarPropertyType(property.type) || isListPropertyType(property.type)) { const placeholder = - property.type === "url" + property.type === "url" || property.type === "multi_url" ? t(($) => $.pickers.custom_property.url_placeholder) : property.type === "number" ? t(($) => $.pickers.custom_property.number_placeholder) @@ -855,6 +856,12 @@ function PropertyFilterOptions({ if (op === "after") return t(($) => $.pickers.custom_property.op_after); return PROPERTY_FILTER_OP_SYMBOLS[op] ?? op; }; + // List types are equality-only: their PROPERTY_FILTER_OPS_BY_TYPE entry is + // empty by design, and looking it up needs the scalar narrowing anyway + // (IssueProperty.type is a lenient string). + const scalarOps = isScalarPropertyType(property.type) + ? (PROPERTY_FILTER_OPS_BY_TYPE[property.type] ?? []) + : []; const opButtons: { op: PropertyFilterOp | "is"; label: string }[] = [ { op: "is", @@ -863,7 +870,7 @@ function PropertyFilterOptions({ ? "=" : t(($) => $.pickers.custom_property.op_is), }, - ...(PROPERTY_FILTER_OPS_BY_TYPE[property.type] ?? []).map((op) => ({ + ...scalarOps.map((op) => ({ op, label: scalarOperatorLabel(op), })), diff --git a/packages/views/issues/components/pickers/custom-property-picker.list.test.tsx b/packages/views/issues/components/pickers/custom-property-picker.list.test.tsx new file mode 100644 index 00000000000..7c9dbdb5172 --- /dev/null +++ b/packages/views/issues/components/pickers/custom-property-picker.list.test.tsx @@ -0,0 +1,179 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "@multica/core/api"; +import { I18nProvider } from "@multica/core/i18n/react"; +import type { Issue, IssueProperty } from "@multica/core/types"; +import enIssues from "../../../locales/en/issues.json"; +import { CustomPropertyValueEditor, CustomPropertyValueInput } from "./custom-property-picker"; + +vi.mock("@multica/core/api", () => ({ + api: { setIssueProperty: vi.fn(), unsetIssueProperty: vi.fn() }, +})); +vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-1" })); + +const property: IssueProperty = { + id: "p-1", workspace_id: "ws-1", name: "Related links", type: "multi_url", + config: {}, position: 1, archived: false, + created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", +}; +const issue: Issue = { + id: "issue-1", workspace_id: "ws-1", number: 1, identifier: "MUL-1", + title: "Related links", description: null, status: "todo", priority: "none", + assignee_type: null, assignee_id: null, creator_type: "member", creator_id: "member-1", + parent_issue_id: null, project_id: null, position: 1, stage: null, + start_date: null, due_date: null, labels: [], metadata: {}, + properties: { [property.id]: ["https://existing.example"] }, + created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", +}; + +function renderEditor() { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + const editor = (currentIssue: Issue, currentProperty = property) => ( + + + + + + ); + const view = render(editor(issue)); + return { + ...view, + showIssue: (next: Issue, nextProperty = property) => view.rerender(editor(next, nextProperty)), + }; +} + +describe("list property editing", () => { + beforeEach(() => vi.resetAllMocks()); + + it("explains a missing URL scheme and keeps the draft editable", async () => { + const user = userEvent.setup(); + renderEditor(); + const input = await screen.findByPlaceholderText("https://…"); + await user.type(input, "example.com{Enter}"); + + expect(await screen.findByRole("alert")).toHaveTextContent("http:// or https://"); + expect(input).toHaveValue("example.com"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(api.setIssueProperty).not.toHaveBeenCalled(); + + await user.clear(input); + await user.type(input, "https://example.com"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("retains a server-rejected URL and clears it only after a successful retry", async () => { + const user = userEvent.setup(); + vi.mocked(api.setIssueProperty).mockRejectedValueOnce(new Error("URL must have a host")); + renderEditor(); + const input = await screen.findByPlaceholderText("https://…"); + await user.type(input, "https://{Enter}"); + + expect(await screen.findByRole("alert")).toHaveTextContent("URL must have a host"); + expect(input).toHaveValue("https://"); + expect(api.setIssueProperty).toHaveBeenCalledWith(issue.id, property.id, [ + "https://existing.example", "https://", + ]); + + vi.mocked(api.setIssueProperty).mockResolvedValueOnce({ + properties: { [property.id]: ["https://existing.example", "https://example.com"] }, + issue_revision: 2, + }); + await user.type(input, "example.com{Enter}"); + await waitFor(() => expect(input).toHaveValue("")); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps the draft but clears the old error when reopened", async () => { + const user = userEvent.setup(); + renderEditor(); + await user.type(await screen.findByRole("textbox", { name: property.name }), "example.com{Enter}"); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: "https://existing.example" })); + + const input = await screen.findByRole("textbox", { name: property.name }); + expect(input).toHaveValue("example.com"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(input).toHaveAttribute("aria-invalid", "false"); + expect(input).not.toHaveAttribute("aria-describedby"); + }); + + it.each(["issue", "property"])("resets the draft and error when switching %s in the same editor", async (target) => { + const user = userEvent.setup(); + const { showIssue } = renderEditor(); + await user.type(await screen.findByRole("textbox", { name: property.name }), "example.com{Enter}"); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + + const nextProperty = target === "property" ? { ...property, id: "p-2" } : property; + showIssue({ + ...issue, + id: target === "issue" ? "issue-2" : issue.id, + properties: { [nextProperty.id]: ["https://next.example"] }, + }, nextProperty); + + expect(await screen.findByRole("textbox", { name: property.name })).toHaveValue(""); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove https://next.example" })).toBeEnabled(); + }); + + it("does not carry an in-flight failure into another issue's draft", async () => { + const user = userEvent.setup(); + let fail!: (error: Error) => void; + vi.mocked(api.setIssueProperty).mockImplementationOnce(() => new Promise((_resolve, reject) => { + fail = reject; + })); + const { showIssue } = renderEditor(); + await user.type(await screen.findByRole("textbox", { name: property.name }), "https://old.example{Enter}"); + await waitFor(() => expect(api.setIssueProperty).toHaveBeenCalledTimes(1)); + + showIssue({ ...issue, id: "issue-2" }); + const input = await screen.findByRole("textbox", { name: property.name }); + expect(input).toHaveValue(""); + expect(input).not.toHaveAttribute("readonly"); + await user.type(input, "https://new.example"); + + await act(async () => fail(new Error("Previous issue write failed"))); + expect(input).toHaveValue("https://new.example"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps the draft and prevents overlapping add/remove requests while saving", async () => { + const user = userEvent.setup(); + let finish!: () => void; + vi.mocked(api.setIssueProperty).mockImplementationOnce(() => new Promise((resolve) => { + finish = () => resolve({ properties: {}, issue_revision: 2 }); + })); + renderEditor(); + const input = await screen.findByPlaceholderText("https://…"); + await user.type(input, "https://example.com{Enter}"); + await waitFor(() => expect(api.setIssueProperty).toHaveBeenCalledTimes(1)); + + expect(input).toHaveValue("https://example.com"); + expect(screen.getByRole("button", { name: "Remove https://existing.example" })).toBeDisabled(); + fireEvent.submit(input.closest("form")!); + expect(api.setIssueProperty).toHaveBeenCalledTimes(1); + + await act(async () => finish()); + await waitFor(() => expect(input).toHaveValue("")); + }); + + it("still appends and clears a synchronous issue-creation draft", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + + + , + ); + const input = await screen.findByPlaceholderText("Enter value…"); + await user.type(input, "Smith, John{Enter}"); + expect(onChange).toHaveBeenCalledWith(["alpha", "Smith, John"]); + await waitFor(() => expect(input).toHaveValue("")); + }); +}); diff --git a/packages/views/issues/components/pickers/custom-property-picker.test.ts b/packages/views/issues/components/pickers/custom-property-picker.test.ts index c224848b19c..60ac76468f1 100644 --- a/packages/views/issues/components/pickers/custom-property-picker.test.ts +++ b/packages/views/issues/components/pickers/custom-property-picker.test.ts @@ -49,4 +49,9 @@ describe("isCustomPropertyReadOnly", () => { expect(isCustomPropertyReadOnly(multi, [MEMBER, AGENT])).toBe(false); expect(isCustomPropertyReadOnly(multi, [AGENT])).toBe(false); }); + + it("keeps multi_text and multi_url editable", () => { + expect(isCustomPropertyReadOnly(property({ type: "multi_text" }), ["alpha"])).toBe(false); + expect(isCustomPropertyReadOnly(property({ type: "multi_url" }), undefined)).toBe(false); + }); }); diff --git a/packages/views/issues/components/pickers/custom-property-picker.tsx b/packages/views/issues/components/pickers/custom-property-picker.tsx index 17a5236912d..792a2b0194a 100644 --- a/packages/views/issues/components/pickers/custom-property-picker.tsx +++ b/packages/views/issues/components/pickers/custom-property-picker.tsx @@ -1,10 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; -import { CalendarDays, Check, ExternalLink } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { CalendarDays, Check, ExternalLink, X } from "lucide-react"; import { toast } from "sonner"; import type { Issue, IssueProperty, IssuePropertyValue } from "@multica/core/types"; -import { hasUnknownActorRef } from "@multica/core/types"; +import { hasUnknownActorRef, isListPropertyType } from "@multica/core/types"; import { useSetIssueProperty, useUnsetIssueProperty, @@ -36,6 +36,8 @@ const EDITABLE_PROPERTY_TYPES = [ "url", "actor", "multi_actor", + "multi_text", + "multi_url", ]; /** @@ -71,6 +73,7 @@ export function isCustomPropertyReadOnly( * actor → member picker (commits and closes) * multi_actor → member picker with toggling items (stays open) * text/number/url → popover with an input, Enter commits + * multi_text/multi_url → popover with removable rows + an appending input * * Archived definitions render read-only: the popover only offers Clear * (the server rejects new values on archived properties but always allows @@ -97,20 +100,28 @@ export function CustomPropertyValueEditor({ return ( { + // List editors await the write and show failures beside the draft. + if (isListPropertyType(property.type) && !isCustomPropertyReadOnly(property, value)) { + const variables = { issueId: issue.id, propertyId: property.id }; + return (next === undefined + ? unsetProperty.mutateAsync(variables) + : setProperty.mutateAsync({ ...variables, value: next }) + ).then(() => {}); + } if (next === undefined) { - unsetProperty.mutate( + return unsetProperty.mutate( { issueId: issue.id, propertyId: property.id }, { onError }, ); - return; } - setProperty.mutate( + return setProperty.mutate( { issueId: issue.id, propertyId: property.id, value: next }, { onError }, ); @@ -122,7 +133,7 @@ export function CustomPropertyValueEditor({ /** * Mutation-free custom-property editor. Create flows use this while an issue * still exists only as a draft; issue detail wraps it above with the normal - * optimistic mutations. + * optimistic mutations. List editors await onChange before clearing the draft. */ export function CustomPropertyValueInput({ property, @@ -136,7 +147,7 @@ export function CustomPropertyValueInput({ }: { property: IssueProperty; value: IssuePropertyValue | undefined; - onChange: (value: IssuePropertyValue | undefined) => void; + onChange: (value: IssuePropertyValue | undefined) => void | Promise; defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; @@ -274,6 +285,20 @@ export function CustomPropertyValueInput({ emptyRow={emptyRow} /> ); + case "multi_text": + case "multi_url": + return ( + + ); case "date": { const date = typeof value === "string" ? dateOnlyToLocalDate(value) : undefined; return ( @@ -459,6 +484,153 @@ function TextishPropertyEditor({ ); } +/** + * List editor for multi_text / multi_url: current entries as removable rows + * above an input that appends on Enter. The popover stays open across + * additions (multi-select interaction); each add/remove commits the whole + * array, and removing the last entry clears the property. + */ +function ListPropertyEditor({ + property, + value, + open, + onOpenChange, + onCommit, + onClear, + trigger, + triggerRender, +}: { + property: IssueProperty; + value: IssuePropertyValue | undefined; + open: boolean; + onOpenChange: (v: boolean) => void; + onCommit: (next: IssuePropertyValue) => void | Promise; + onClear: () => void | Promise; + trigger?: React.ReactNode; + triggerRender?: React.ReactElement>; +}) { + const { t } = useT("issues"); + const [draft, setDraft] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + const errorId = useId(); + + useEffect(() => { + if (open) setError(null); + }, [open]); + + const items = Array.isArray(value) ? value : []; + const placeholder = + property.type === "multi_url" + ? t(($) => $.pickers.custom_property.url_placeholder) + : t(($) => $.pickers.custom_property.value_placeholder); + + const save = async (next: string[], clearDraft = false) => { + if (savingRef.current) return; + savingRef.current = true; + setSaving(true); + setError(null); + try { + if (next.length === 0) await onClear(); + else await onCommit(next); + if (clearDraft) setDraft(""); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } finally { + savingRef.current = false; + setSaving(false); + } + }; + + const add = () => { + if (savingRef.current) return; + const trimmed = draft.trim(); + if (!trimmed) return; + if (property.type === "multi_url" && !/^https?:\/\//i.test(trimmed)) { + setError(t(($) => $.pickers.custom_property.url_scheme_required)); + return; + } + if (items.includes(trimmed)) { + setDraft(""); + setError(null); + return; + } + void save([...items, trimmed], true); + }; + + return ( + + + {trigger} + + + {items.length > 0 && ( +
    + {items.map((item) => ( +
  • + {item} + {property.type === "multi_url" && ( + + )} + +
  • + ))} +
+ )} +
{ + event.preventDefault(); + add(); + }} + className="flex items-center gap-2" + aria-busy={saving} + > + { + setDraft(event.target.value); + setError(null); + }} + placeholder={placeholder} + className="h-8" + /> +
+ {error && ( + + )} +
+
+ ); +} + /** * Read view of a custom property value, shared by row triggers everywhere * (sidebar rows now; cards/filters later). Option ids resolve to named, @@ -535,6 +707,32 @@ export function CustomPropertyValueDisplay({ } /> ); + case "multi_text": + case "multi_url": { + const items = Array.isArray(value) ? value : []; + if (items.length === 0) { + return ( + + {t(($) => $.pickers.custom_property.empty)} + + ); + } + return ( + + {items.map((item) => ( + + {property.type === "multi_url" && ( + + )} + {item} + + ))} + + ); + } case "date": return ( diff --git a/packages/views/issues/components/table-view.tsx b/packages/views/issues/components/table-view.tsx index 3badfbc2297..fa51d5cd8b1 100644 --- a/packages/views/issues/components/table-view.tsx +++ b/packages/views/issues/components/table-view.tsx @@ -921,6 +921,9 @@ function propertyDisplayValue( .map((option) => option.name) .join(", "); } + if (property.type === "multi_text" || property.type === "multi_url") { + return Array.isArray(value) ? value.join(", ") : String(value); + } if (isActorPropertyType(property.type)) { return actorRefsFromValue(value) .map((ref) => (getActorName ? getActorName(ref.kind, ref.id) : formatActorRef(ref.kind, ref.id))) @@ -1076,7 +1079,7 @@ function IssueTableHeaderCell({ const property = propertyId ? meta.propertyById.get(propertyId) : undefined; const staticSort = propertyId ? property && - !["multi_select", "checkbox", "actor", "multi_actor"].includes(property.type) + !["multi_select", "checkbox", "actor", "multi_actor", "multi_text", "multi_url"].includes(property.type) ? (`property:${propertyId}` as SortField) : undefined : SORTABLE_COLUMNS[key as TableSystemColumnKey]; diff --git a/packages/views/issues/utils/filter.test.ts b/packages/views/issues/utils/filter.test.ts index 8b15f2e8607..f32a03344e5 100644 --- a/packages/views/issues/utils/filter.test.ts +++ b/packages/views/issues/utils/filter.test.ts @@ -500,6 +500,17 @@ describe("property filters", () => { expect(result.map((i) => i.id)).toEqual(["P2"]); }); + it("multi_text/multi_url match any single element exactly", () => { + const aliasId = "prop-aliases"; + const withAlpha = makeIssue({ id: "L1", properties: { [aliasId]: ["beta", "alpha"] } }); + const without = makeIssue({ id: "L2", properties: { [aliasId]: ["beta"] } }); + const result = filterIssues([withAlpha, without], { + ...NO_FILTER, + propertyFilters: { [aliasId]: ["alpha"] }, + }); + expect(result.map((i) => i.id)).toEqual(["L1"]); + }); + it("checkbox values match the true/false pseudo-options", () => { const result = filterIssues([checked, unset], { ...NO_FILTER, diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index c3fee3c8dfa..b61648c3009 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -872,9 +872,11 @@ "false_label": "No", "value_placeholder": "Enter value…", "url_placeholder": "https://…", + "url_scheme_required": "Start the URL with http:// or https://.", "number_placeholder": "0", "actor_search_placeholder": "Search members…", "open_link": "Open link", + "remove_item": "Remove {{value}}", "archived_hint": "Archived property — value is read-only", "unknown_value": "Unavailable", "unknown_value_hint": "This value was set by a newer version of Multica and can't be edited here — update the app, or clear it" diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index 136349db310..4e37ee3db29 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -544,7 +544,9 @@ "checkbox": "Checkbox", "url": "URL", "actor": "Member", - "multi_actor": "Members" + "multi_actor": "Members", + "multi_text": "Text list", + "multi_url": "URL list" }, "actions": { "open": "Actions for {{name}}", diff --git a/packages/views/locales/fr/issues.json b/packages/views/locales/fr/issues.json index 9033f26735d..3528dc16329 100644 --- a/packages/views/locales/fr/issues.json +++ b/packages/views/locales/fr/issues.json @@ -872,9 +872,11 @@ "false_label": "Non", "value_placeholder": "Saisir une valeur…", "url_placeholder": "https://…", + "url_scheme_required": "L’URL doit commencer par http:// ou https://.", "number_placeholder": "0", "actor_search_placeholder": "Rechercher des membres…", "open_link": "Ouvrir le lien", + "remove_item": "Retirer {{value}}", "archived_hint": "Propriété archivée — la valeur est en lecture seule", "unknown_value": "Indisponible", "unknown_value_hint": "Cette valeur a été définie par une version plus récente de Multica et ne peut pas être modifiée ici — mettez l'application à jour, ou effacez-la" diff --git a/packages/views/locales/fr/settings.json b/packages/views/locales/fr/settings.json index 7659b9c036c..36764c3f27f 100644 --- a/packages/views/locales/fr/settings.json +++ b/packages/views/locales/fr/settings.json @@ -544,7 +544,9 @@ "checkbox": "Case à cocher", "url": "URL", "actor": "Membre", - "multi_actor": "Membres" + "multi_actor": "Membres", + "multi_text": "Liste de textes", + "multi_url": "Liste d'URL" }, "actions": { "open": "Actions pour {{name}}", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index 54758ab5ee7..b7d546a85f4 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -833,9 +833,11 @@ "false_label": "いいえ", "value_placeholder": "値を入力…", "url_placeholder": "https://…", + "url_scheme_required": "URL は http:// または https:// で始めてください。", "number_placeholder": "0", "actor_search_placeholder": "メンバーを検索…", "open_link": "リンクを開く", + "remove_item": "{{value}} を削除", "archived_hint": "アーカイブ済みプロパティ — 値は読み取り専用です", "unknown_value": "表示できません", "unknown_value_hint": "この値は新しいバージョンの Multica で設定されており、ここでは編集できません。アプリを更新するか、値をクリアしてください" diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json index 09f9af4ed48..26d33572c70 100644 --- a/packages/views/locales/ja/settings.json +++ b/packages/views/locales/ja/settings.json @@ -540,7 +540,9 @@ "checkbox": "チェックボックス", "url": "URL", "actor": "メンバー", - "multi_actor": "メンバー(複数選択)" + "multi_actor": "メンバー(複数選択)", + "multi_text": "テキストリスト", + "multi_url": "URLリスト" }, "actions": { "open": "{{name}} の操作", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index 651e2f75949..b737da945ff 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -833,9 +833,11 @@ "false_label": "아니요", "value_placeholder": "값 입력…", "url_placeholder": "https://…", + "url_scheme_required": "URL은 http:// 또는 https://로 시작해야 합니다.", "number_placeholder": "0", "actor_search_placeholder": "멤버 검색…", "open_link": "링크 열기", + "remove_item": "{{value}} 제거", "archived_hint": "보관된 속성 — 값은 읽기 전용입니다", "unknown_value": "표시할 수 없음", "unknown_value_hint": "이 값은 최신 버전의 Multica에서 설정되어 여기서는 편집할 수 없습니다. 앱을 업데이트하거나 값을 지우세요" diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json index c9c848ce7ed..56dbe605291 100644 --- a/packages/views/locales/ko/settings.json +++ b/packages/views/locales/ko/settings.json @@ -540,7 +540,9 @@ "checkbox": "체크박스", "url": "URL", "actor": "멤버", - "multi_actor": "멤버(다중 선택)" + "multi_actor": "멤버(다중 선택)", + "multi_text": "텍스트 목록", + "multi_url": "URL 목록" }, "actions": { "open": "{{name}} 작업", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index cfc309dba76..61877ceb6fa 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -827,9 +827,11 @@ "false_label": "否", "value_placeholder": "输入值…", "url_placeholder": "https://…", + "url_scheme_required": "URL 必须以 http:// 或 https:// 开头。", "number_placeholder": "0", "actor_search_placeholder": "搜索成员…", "open_link": "打开链接", + "remove_item": "移除 {{value}}", "archived_hint": "已归档属性——值为只读", "unknown_value": "无法显示", "unknown_value_hint": "该值由更新版本的 Multica 设置,当前版本无法编辑——请更新应用,或清除该值", diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index f92c7a0caec..9076af09775 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -540,7 +540,9 @@ "checkbox": "勾选", "url": "链接", "actor": "成员", - "multi_actor": "成员(多选)" + "multi_actor": "成员(多选)", + "multi_text": "文本列表", + "multi_url": "链接列表" }, "actions": { "open": "{{name}} 的操作", diff --git a/packages/views/settings/components/properties-tab.tsx b/packages/views/settings/components/properties-tab.tsx index 9d0605a9c0b..247dd0456c0 100644 --- a/packages/views/settings/components/properties-tab.tsx +++ b/packages/views/settings/components/properties-tab.tsx @@ -353,6 +353,10 @@ export function PropertyTypeLabel({ type }: { type: string }) { return <>{t(($) => $.properties.types.actor)}; case "multi_actor": return <>{t(($) => $.properties.types.multi_actor)}; + case "multi_text": + return <>{t(($) => $.properties.types.multi_text)}; + case "multi_url": + return <>{t(($) => $.properties.types.multi_url)}; default: // Forward compat: newer servers may ship types this build doesn't know. return <>{type}; diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go index 0b9dfee3b15..5af1550d161 100644 --- a/server/cmd/multica/cmd_issue.go +++ b/server/cmd/multica/cmd_issue.go @@ -255,7 +255,13 @@ var issueChildrenCmd = &cobra.Command{ var issueCreateCmd = &cobra.Command{ Use: "create", Short: "Create a new issue", - RunE: runIssueCreate, + Long: `Create a new issue. Use --property "Name=Value" to set a custom property +atomically with creation (repeatable, one distinct property per flag). +For multi_text / multi_url, use a JSON array of strings when entries contain +commas or the value starts with "["; simple entries can use comma-separated values: + multica issue create --title "Review specs" --property 'Aliases=["Smith, John","[draft] spec"]' + multica issue create --title "Read docs" --property 'Related links=["https://en.wikipedia.org/wiki/Washington,_D.C."]'`, + RunE: runIssueCreate, } var issueUpdateCmd = &cobra.Command{ @@ -632,7 +638,7 @@ func init() { issueCreateCmd.Flags().String("output", "json", "Output format: table or json") issueCreateCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times). Each file is uploaded and its markdown reference is appended to the description, which is what makes it render on the issue page") issueCreateCmd.Flags().StringSlice("attachment-id", nil, "Existing attachment UUID(s) to bind to the created issue (can be specified multiple times)") - issueCreateCmd.Flags().StringArray("property", nil, `Set a custom property atomically with creation as "Name=Value" (repeatable, one distinct property per flag). Multi-value properties use comma-separated values inside one flag. Property and option/member names are case-insensitive; UUIDs are accepted. Filter-only __none__, >=, <=, and != forms are rejected.`) + issueCreateCmd.Flags().StringArray("property", nil, `Set a custom property atomically with creation as "Name=Value" (repeatable, one distinct property per flag). Multi-value properties use comma-separated values; multi_text/multi_url also accept JSON arrays (required for commas in entries or values starting with "["). Property and option/member names are case-insensitive; UUIDs are accepted. Filter-only __none__, >=, <=, and != forms are rejected.`) // issue update issueUpdateCmd.Flags().String("title", "", "New title") diff --git a/server/cmd/multica/cmd_issue_property_filter_test.go b/server/cmd/multica/cmd_issue_property_filter_test.go index fff043972aa..0aacf9fea6f 100644 --- a/server/cmd/multica/cmd_issue_property_filter_test.go +++ b/server/cmd/multica/cmd_issue_property_filter_test.go @@ -358,3 +358,47 @@ func TestRunIssueListFetchesCatalogOnce(t *testing.T) { t.Fatalf("expected one /api/issues request, got %d", len(*queries)) } } + +func TestEncodeIssuePropertyListValues(t *testing.T) { + // Both write paths — `issue property set --value` and + // `issue create --property` — go through encodeIssuePropertyValue, so this + // covers the list-value forms for both commands. + multiText := propertyDTO{ID: "p-text", Name: "Aliases", Type: "multi_text"} + multiURL := propertyDTO{ID: "p-url", Name: "Related docs", Type: "multi_url"} + encode := func(property propertyDTO, raw string) ([]byte, error) { + return encodeIssuePropertyValue(t.Context(), nil, &memberDirectory{}, property, raw) + } + + cases := []struct { + name string + property propertyDTO + raw string + want string + wantErr string + }{ + {"comma form keeps simple entries", multiText, "alpha,beta", `["alpha","beta"]`, ""}, + {"comma form skips empty tokens", multiText, "alpha, ,beta", `["alpha","beta"]`, ""}, + {"JSON form preserves a comma in text", multiText, `["Smith, John","Doe, Jane"]`, `["Smith, John","Doe, Jane"]`, ""}, + {"JSON form preserves a comma in a URL", multiURL, `["https://en.wikipedia.org/wiki/Washington,_D.C."]`, `["https://en.wikipedia.org/wiki/Washington,_D.C."]`, ""}, + {"JSON form mixes both list types", multiURL, `["https://a.example/x?q=1,2","https://b.example"]`, `["https://a.example/x?q=1,2","https://b.example"]`, ""}, + {"malformed JSON array is rejected", multiText, `["Smith, John", 7]`, "", "JSON array of strings"}, + {"empty value is rejected", multiText, "", "", "at least one entry"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := encode(tc.property, tc.raw) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("encode(%q) error = %v, want containing %q", tc.raw, err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("encode(%q): %v", tc.raw, err) + } + if string(got) != tc.want { + t.Fatalf("encode(%q) = %s, want %s", tc.raw, got, tc.want) + } + }) + } +} diff --git a/server/cmd/multica/cmd_property.go b/server/cmd/multica/cmd_property.go index fb7fb3f8513..a8b911eb01f 100644 --- a/server/cmd/multica/cmd_property.go +++ b/server/cmd/multica/cmd_property.go @@ -24,7 +24,7 @@ import ( // multica property {list|get|create|update|archive|unarchive} — workspace // custom property definitions, and multica issue property {list|set|unset} — // typed values on a single issue. See server/internal/handler/property.go -// for the validation contract (9 types, 20 active definitions/workspace, +// for the validation contract (11 types, 20 active definitions/workspace, // owner/admin-only definition management, agents rejected on definition // writes). // @@ -77,14 +77,23 @@ var propertyCreateCmd = &cobra.Command{ Use: "create", Short: "Create a property definition (workspace owner/admin only)", Long: `Create a property definition. Types: text, number, select, multi_select, -date, checkbox, url, actor, multi_actor. Select types take repeatable --option -flags: +date, checkbox, url, actor, multi_actor, multi_text, multi_url. Select types +take repeatable --option flags: multica property create --name Severity --type select \ --option "Critical:#ef4444" --option "Major:#f59e0b" --option "Minor:#6b7280" The ":#rrggbb" color suffix is optional. The actor types hold workspace members; they take no options: - multica property create --name Reviewer --type actor`, + multica property create --name Reviewer --type actor + +multi_text / multi_url hold free-form lists without options: + multica property create --name "Related links" --type multi_url +Set their values with issue property set --value or issue create --property. +Use comma-separated values for simple entries, or a JSON array of strings. +JSON is required for entries containing commas or input starting with "[": + multica issue property set --name "Related links" \ + --value '["https://en.wikipedia.org/wiki/Washington,_D.C."]' + multica issue property set --name Aliases --value '["[draft] spec"]'`, Args: exactArgs(0), RunE: runPropertyCreate, } @@ -136,7 +145,14 @@ var issuePropertySetCmd = &cobra.Command{ checkbox --value true|false number --value 3.5 date --value 2026-07-13 - text / url --value "any string"`, + text / url --value "any string" + multi_text --value "alpha,beta" (comma-separated strings) + multi_url --value "https://a.example,https://b.example" +For multi_text / multi_url, entries containing commas or input starting with +"[" must use a JSON array of strings instead: + multi_url --value '["https://en.wikipedia.org/wiki/Washington,_D.C."]' + multi_text --value '["Smith, John","Doe, Jane"]' + multi_text --value '["[draft] spec"]'`, Args: exactArgs(1), RunE: runIssuePropertySet, } @@ -161,7 +177,7 @@ func init() { propertyGetCmd.Flags().String("output", "json", "Output format: table or json") propertyCreateCmd.Flags().String("output", "table", "Output format: table or json") propertyCreateCmd.Flags().String("name", "", "Property name (required)") - propertyCreateCmd.Flags().String("type", "", "Property type: text, number, select, multi_select, date, checkbox, url, actor, multi_actor (required)") + propertyCreateCmd.Flags().String("type", "", "Property type: text, number, select, multi_select, date, checkbox, url, actor, multi_actor, multi_text, multi_url (required)") propertyCreateCmd.Flags().String("description", "", "Property description") propertyCreateCmd.Flags().String("icon", "", "Property icon key from the Web picker (for example, flag, tag, or shield)") propertyCreateCmd.Flags().StringArray("option", nil, `Select option as "Name" or "Name:#rrggbb" (repeatable; select types only)`) @@ -574,6 +590,29 @@ func encodeIssuePropertyValue(ctx context.Context, client *cli.APIClient, direct return nil, fmt.Errorf("--value must list at least one member") } return json.Marshal(refs) + case "multi_text", "multi_url": + // JSON array form first: it round-trips every entry the API accepts, + // including ones that contain commas ("Smith, John", + // "https://en.wikipedia.org/wiki/Washington,_D.C."), which the comma + // form below would split. Comma form stays for simple entries. + if strings.HasPrefix(raw, "[") { + var items []string + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil, fmt.Errorf("--value must be a JSON array of strings (or a comma-separated list for entries without commas)") + } + return json.Marshal(items) + } + parts := strings.Split(raw, ",") + items := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + items = append(items, trimmed) + } + } + if len(items) == 0 { + return nil, fmt.Errorf("--value must list at least one entry") + } + return json.Marshal(items) case "number": if _, err := strconv.ParseFloat(raw, 64); err != nil { return nil, fmt.Errorf("value %q is not a valid number", raw) @@ -660,12 +699,15 @@ func actorPropertyName(actorNames map[string]string, ref string) string { return ref } -// issuePropertyDisplayValues resolves each item of a multi_select or -// multi_actor value to its display name. The result stays index-parallel -// with the stored array (a non-string item renders as JSON rather than being -// dropped) and is nil for every other type or a non-array value. +// issuePropertyDisplayValues resolves each item of a multi-value property to +// its display string: option ids to names (multi_select), actor references to +// member names (multi_actor), elements as-is (multi_text / multi_url). The +// result stays index-parallel with the stored array (a non-string item renders +// as JSON rather than being dropped) and is nil for every other type or a +// non-array value. func issuePropertyDisplayValues(property propertyDTO, value any, actorNames map[string]string) []string { - if property.Type != "multi_select" && property.Type != "multi_actor" { + if property.Type != "multi_select" && property.Type != "multi_actor" && + property.Type != "multi_text" && property.Type != "multi_url" { return nil } items, ok := value.([]any) @@ -1106,7 +1148,8 @@ func resolvePropertyFilterValue(ctx context.Context, client *cli.APIClient, dire return "", fmt.Errorf("--property %s: value %q is not a date in YYYY-MM-DD form", property.Name, trimmed) } return trimmed, nil - case "url": + case "url", "multi_url": + // One --value token matches one element exactly. if len(trimmed) > maxPropertyURLValueLen { return "", fmt.Errorf("--property %s: value must be %d characters or fewer", property.Name, maxPropertyURLValueLen) } @@ -1114,7 +1157,7 @@ func resolvePropertyFilterValue(ctx context.Context, client *cli.APIClient, dire return "", fmt.Errorf("--property %s: value %q is not an http(s) URL", property.Name, trimmed) } return trimmed, nil - case "text": + case "text", "multi_text": if utf8.RuneCountInString(raw) > maxPropertyTextValueLen { return "", fmt.Errorf("--property %s: value must be %d characters or fewer", property.Name, maxPropertyTextValueLen) } diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 85745dab5cd..be0e3537555 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -6543,18 +6543,12 @@ func providerNeedsInlineSystemPrompt(provider string) bool { // changes, so binding it to workdir reuse discards healthy conversation history // and forces the model to reconstruct it through `multica chat history`. // -// A matching workdir is not sufficient on its own. Hermes keys its sessions to -// HERMES_HOME — the per-task overlay under envRoot — not to the cwd, and the -// two keys come apart precisely in the local_directory flow: reuse is disabled -// there (shouldReusePriorWorkdir), so every task builds a fresh overlay with an -// empty state.db, while envWorkDir stays the user's own directory and therefore -// still equals PriorWorkDir. The gate read "reused" and forwarded a session id -// that could not possibly resolve, and Hermes answers an unresolvable resume by -// silently starting over (GH #6806). sessionHomeReachable is the provider's own -// answer to "can a prior session still be found here?" — for Hermes, whether -// the conversation's session store got mounted (execenv.Environment -// HermesSessionStore) — and false drops the resume with the same disclosure as -// a workdir mismatch. +// Hermes is also independent of cwd: its transcript lives in HERMES_HOME's +// state.db. sessionHomeReachable checks whether the conversation-scoped store +// mounted by execenv holds history, or whether the task-local home was reused. +// Requiring the prior cwd as well would discard reachable history whenever a +// local_directory task gets a new worktree (#9062). An empty or unavailable +// store must still drop the resume, even when the cwd matches (#6806). // sameExistingDir reports whether two paths name the same existing directory. // False when either cannot be stat'd, which is the safe answer for cwd-keyed // providers: an absent prior workdir means there is nothing to resume from. @@ -6577,6 +6571,8 @@ func gateResumeToReachableSession(task *Task, taskCtx *execenv.TaskContextForEnv var reachable bool if providerUsesPiSessionFile(provider) { reachable = piSessionResumable(task.PriorSessionID, refusesMissingSessionCwd) + } else if provider == "hermes" { + reachable = sessionHomeReachable } else { // Compare the directories, not the spelling. Reuse runs in the canonical // path it validated and locked, which need not be character-identical to diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index 3bda712758e..d2c550f7faa 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -2021,6 +2021,106 @@ func TestGateResumeToReachableSession(t *testing.T) { } } +// Hermes resumes from HERMES_HOME, independently of the task's cwd (#9062). +func TestGateHermesResumeToSessionHome(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + sameWorkdir bool + history bool + envReused bool + noStore bool + want bool + }{ + {name: "changed worktree with history", history: true, want: true}, + {name: "in place with history", sameWorkdir: true, history: true, want: true}, + {name: "changed worktree with unavailable history"}, + {name: "in place with unavailable history", sameWorkdir: true}, + {name: "empty store despite reused environment", sameWorkdir: true, envReused: true}, + {name: "unmounted store in fresh environment", noStore: true}, + {name: "task local history in reused environment", sameWorkdir: true, noStore: true, envReused: true, want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + priorDir, workDir := t.TempDir(), t.TempDir() + if tt.sameWorkdir { + workDir = priorDir + } + env := &execenv.Environment{HermesSessionStore: "conversation-store", HermesSessionHistoryPresent: tt.history} + if tt.noStore { + env.HermesSessionStore = "" + } + task := Task{PriorSessionID: "session-1", PriorWorkDir: priorDir} + taskCtx := execenv.TaskContextForEnv{PriorSessionResumed: true} + got := gateResumeToReachableSession(&task, &taskCtx, "hermes", workDir, + sessionHomeReachable("hermes", env, tt.envReused), false, slog.Default()) + if got != tt.want || (task.PriorSessionID == "session-1") != tt.want || taskCtx.PriorSessionResumed != tt.want { + t.Fatalf("resume = %v, session = %q, resumed = %v; want %v", got, task.PriorSessionID, taskCtx.PriorSessionResumed, tt.want) + } + if task.PriorSessionResumeUnavailable != !tt.want || taskCtx.PriorSessionResumeUnavailable != !tt.want { + t.Fatal("session continuity notice does not match reachability") + } + }) + } +} + +func TestHermesPreparedSessionReachability(t *testing.T) { + // Keep profile/store resolution inside synthetic homes, never the user's. + t.Setenv("MULTICA_TASK_CONFIG_ROOT", "") + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + sourceHome, root := t.TempDir(), t.TempDir() + taskNumber := 0 + prepare := func(t *testing.T, agentID, issueID string) *execenv.Environment { + t.Helper() + taskCtx := execenv.TaskContextForEnv{AgentID: agentID, IssueID: issueID, + AgentSkills: []execenv.SkillContextForEnv{{Name: "fixture", Content: "synthetic skill"}}, + } + taskNumber++ + env, err := execenv.Prepare(execenv.PrepareParams{ + WorkspacesRoot: root, WorkspaceID: "workspace-1", TaskID: fmt.Sprintf("task-%012d", taskNumber), + Provider: "hermes", HermesSourceHome: sourceHome, Task: taskCtx, + HermesSessionStore: execenv.HermesSessionStorePath("", agentID, sourceHome, taskCtx), + }, slog.Default()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = env.Cleanup(true) }) + return env + } + first := prepare(t, "agent-1", "issue-1") + if first.HermesSessionStore == "" { + if runtime.GOOS == "windows" { + t.Skip("host cannot mount Hermes session stores") + } + t.Fatal("Hermes session store was not mounted") + } + if err := os.WriteFile(filepath.Join(first.HermesHome, "state.db"), []byte("synthetic transcript"), 0o600); err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + name, agentID, issueID string + want bool + }{ + {"same conversation in fresh workdir", "agent-1", "issue-1", true}, + {"different issue", "agent-1", "issue-2", false}, + {"different agent", "agent-2", "issue-1", false}, + } { + t.Run(tt.name, func(t *testing.T) { + env := prepare(t, tt.agentID, tt.issueID) + if sameExistingDir(first.WorkDir, env.WorkDir) { + t.Fatal("fixture must prepare a different task workdir") + } + task := Task{PriorSessionID: "session-1", PriorWorkDir: first.WorkDir} + taskCtx := execenv.TaskContextForEnv{PriorSessionResumed: true} + if got := gateResumeToReachableSession(&task, &taskCtx, "hermes", env.WorkDir, + sessionHomeReachable("hermes", env, false), false, slog.Default()); got != tt.want { + t.Fatalf("reachable = %v, want %v", got, tt.want) + } + }) + } +} + func TestGatePiResumeToSessionFile(t *testing.T) { t.Parallel() diff --git a/server/internal/daemon/execenv/hermes_sessions.go b/server/internal/daemon/execenv/hermes_sessions.go index 140ccac15f2..98f55be6bbf 100644 --- a/server/internal/daemon/execenv/hermes_sessions.go +++ b/server/internal/daemon/execenv/hermes_sessions.go @@ -210,10 +210,27 @@ func mountHermesSessionDB(hermesHome, storeDir string, logger *slog.Logger) (her const hermesSessionLinkStagingEntry = ".multica-session-link" // hermesStoreHasSessionDB reports whether storeDir holds a session database -// with content. A zero-length file is what SQLite leaves after an `open` that -// never wrote a page, and resuming against it is the same amnesia as an absent -// one — so it counts as no history, not as history. +// with readable content. A zero-length file is what SQLite leaves after an +// `open` that never wrote a page; resuming against it is the same as an absent +// database, so it counts as no history. func hermesStoreHasSessionDB(storeDir string) bool { + path := filepath.Join(storeDir, hermesSessionDBEntry) + fi, err := os.Stat(path) + if err != nil || !fi.Mode().IsRegular() || fi.Size() == 0 { + return false + } + db, err := os.Open(path) + if err != nil { + return false + } + defer db.Close() + return true +} + +// hermesStoreContainsSessionDB protects existing history during migration, +// even when it cannot currently be opened for resume. Read permissions do not +// prevent unlinking a database from a writable directory. +func hermesStoreContainsSessionDB(storeDir string) bool { fi, err := os.Stat(filepath.Join(storeDir, hermesSessionDBEntry)) return err == nil && fi.Mode().IsRegular() && fi.Size() > 0 } @@ -234,7 +251,7 @@ func hermesStoreHasSessionDB(storeDir string) bool { // Only an empty store is migrated into — a store that already holds a database // is this conversation's real history and must never be overwritten. func migrateHermesTaskSessionDB(hermesHome, storeDir string, logger *slog.Logger) error { - if hermesStoreHasSessionDB(storeDir) { + if hermesStoreContainsSessionDB(storeDir) { return nil // store already holds this conversation — never overwrite it } @@ -306,7 +323,7 @@ func migrateHermesTaskSessionDB(hermesHome, storeDir string, logger *slog.Logger func publishHermesSessionStaging(staging, storeDir string) (bool, error) { hermesSessionPublishMu.Lock() defer hermesSessionPublishMu.Unlock() - if hermesStoreHasSessionDB(storeDir) { + if hermesStoreContainsSessionDB(storeDir) { return false, nil // a competitor published a real transcript first } if hermesSessionPublishBarrier != nil { @@ -315,7 +332,7 @@ func publishHermesSessionStaging(staging, storeDir string) (bool, error) { if err := removeHermesSessionDBFamily(storeDir); err != nil { return false, err } - return promoteHermesStoreStaging(staging, storeDir, hermesStoreHasSessionDB) + return promoteHermesStoreStaging(staging, storeDir, hermesStoreContainsSessionDB) } // hermesSessionPublishMu serializes every session-store publish in this diff --git a/server/internal/daemon/execenv/hermes_sessions_test.go b/server/internal/daemon/execenv/hermes_sessions_test.go index 2996ededbd5..e2404a2ea0c 100644 --- a/server/internal/daemon/execenv/hermes_sessions_test.go +++ b/server/internal/daemon/execenv/hermes_sessions_test.go @@ -229,6 +229,53 @@ func TestPrepareHermesHomeMigrationNeverOverwritesStore(t *testing.T) { } } +func TestPrepareHermesHomeMigrationPreservesUnreadableStore(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permissions required") + } + sharedHome := t.TempDir() + store := t.TempDir() + hermesHome := filepath.Join(t.TempDir(), "hermes-home") + skills := []SkillContextForEnv{{Name: "deploy", Content: "# Deploy"}} + db := filepath.Join(store, "state.db") + mustWrite(t, db, "the real transcript") + mustWrite(t, filepath.Join(store, "state.db-wal"), "the real uncheckpointed history") + if err := os.Chmod(db, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(db, 0o600) }) + if f, err := os.Open(db); err == nil { + f.Close() + t.Skip("current user can read files despite permissions") + } + staging := t.TempDir() + mustWrite(t, filepath.Join(staging, "state.db"), "competing transcript") + mustWrite(t, filepath.Join(staging, "state.db-wal"), "competing WAL") + if published, err := publishHermesSessionStaging(staging, store); err != nil || published { + t.Fatalf("publish over unreadable history = %v, %v; want false, nil", published, err) + } + if _, err := prepareHermesHome(hermesHome, sharedHome, false, skills, nil, "", "", testLogger()); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(hermesHome, "state.db"), "stray task-local database") + mustWrite(t, filepath.Join(hermesHome, "state.db-wal"), "stray task-local WAL") + if _, err := prepareHermesHome(hermesHome, sharedHome, false, skills, nil, "", store, testLogger()); err != nil { + t.Fatal(err) + } + if hermesStoreHasSessionDB(store) { + t.Error("unreadable history must not be resumable") + } + if err := os.Chmod(db, 0o600); err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"state.db": "the real transcript", "state.db-wal": "the real uncheckpointed history"} { + got, err := os.ReadFile(filepath.Join(store, name)) + if err != nil || string(got) != want { + t.Errorf("persistent %s = %q, %v; want %q", name, got, err, want) + } + } +} + // TestPrepareHermesHomeSessionMountIsIdempotent covers Reuse: rebuilding the // overlay for a follow-up turn in the same task directory must leave the link // (and therefore the live database) alone. @@ -843,3 +890,23 @@ func requireSymlinks(t *testing.T) { t.Skipf("symlinks unavailable on this host: %v", err) } } + +func TestHermesStoreHasSessionDBUnreadable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permissions required") + } + store := t.TempDir() + db := filepath.Join(store, "state.db") + mustWrite(t, db, "transcript") + if err := os.Chmod(db, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(db, 0o600) }) + if f, err := os.Open(db); err == nil { + f.Close() + t.Skip("current user can read files despite permissions") + } + if hermesStoreHasSessionDB(store) { + t.Fatal("unreadable session database reported as reachable") + } +} diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 05cae676388..879cf4ac580 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -774,11 +774,11 @@ func splitSearchTerms(q string) []string { return terms } -// identifierNumberRe matches patterns like "MUL-123" or "ABC-45". -var identifierNumberRe = regexp.MustCompile(`(?i)^[a-z]+-(\d+)$`) +// identifierNumberRe matches letter-leading prefixes like "MUL-123" or "V2-12". +var identifierNumberRe = regexp.MustCompile(`(?i)^[a-z][a-z0-9]*-(\d+)$`) // parseQueryNumber extracts an issue number from the query if it looks like -// an identifier (e.g. "MUL-123") or a bare number (e.g. "123"). +// an identifier (e.g. "MUL-123" or "V2-12") or a bare number (e.g. "123"). func parseQueryNumber(q string) (int, bool) { q = strings.TrimSpace(q) // Check for identifier pattern like "MUL-123" diff --git a/server/internal/handler/issue_table_facets.go b/server/internal/handler/issue_table_facets.go index 58b4a1f47f2..0b0481dc31b 100644 --- a/server/internal/handler/issue_table_facets.go +++ b/server/internal/handler/issue_table_facets.go @@ -265,13 +265,15 @@ GROUP BY r.agent_id`, len(compiled.args)+1, compiled.where) // Unset issues count under the "__none__" bucket so the filter menu's // "No value" option carries a real count (issues without the key). query = fmt.Sprintf(`SELECT COALESCE(i.properties ->> %s, '__none__'), COUNT(*)::bigint FROM issue i WHERE %s AND (jsonb_typeof(i.properties -> %s) = 'string' OR NOT (i.properties ? %s)) GROUP BY 1`, propertyKey, compiled.where, propertyKey, propertyKey) - case "text", "url", "date", "number": - // Scalar values are free-form, so grouping by distinct value would - // return one row per observed value with no bound. The filter menu - // only reads the "__none__" bucket for these types, so compute just - // the two bounded buckets instead of pulling the whole value space. - // Key existence is exact for "no value": stored values can never be - // null or empty, so a present key is always a set value. + case "text", "url", "date", "number", "multi_text", "multi_url": + // Scalar and free-form-list values are unbounded distinct-value + // spaces (multi_text/multi_url enumerate arbitrary user strings), + // so grouping by distinct value would return one row per observed + // value with no bound. The filter menu only reads the "__none__" + // bucket for these types, so compute just the two bounded buckets + // instead of pulling the whole value space. Key existence is exact + // for "no value": stored values can never be null or empty, so a + // present key is always a set value. query = fmt.Sprintf(`SELECT CASE WHEN i.properties ? %s THEN '__set__' ELSE '__none__' END, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, propertyKey, compiled.where) case "multi_select", "multi_actor": query = fmt.Sprintf(`SELECT COALESCE(property_value.value, '__none__'), COUNT(DISTINCT i.id)::bigint FROM issue i JOIN LATERAL (SELECT jsonb_array_elements_text(CASE WHEN jsonb_typeof(i.properties -> %s) = 'array' THEN i.properties -> %s ELSE '[]'::jsonb END) AS value UNION ALL SELECT NULL WHERE NOT (i.properties ? %s)) property_value(value) ON TRUE WHERE %s GROUP BY 1`, propertyKey, propertyKey, propertyKey, compiled.where) diff --git a/server/internal/handler/property.go b/server/internal/handler/property.go index bbd506fcdcd..d237899397c 100644 --- a/server/internal/handler/property.go +++ b/server/internal/handler/property.go @@ -52,7 +52,7 @@ const ( maxPropertyActorValues = 20 ) -var validPropertyTypes = []string{"text", "number", "select", "multi_select", "date", "checkbox", "url", "actor", "multi_actor"} +var validPropertyTypes = []string{"text", "number", "select", "multi_select", "date", "checkbox", "url", "actor", "multi_actor", "multi_text", "multi_url"} // Property icons use stable catalog keys that the Web client maps to Lucide // glyphs. Keeping this allowlist at the API boundary prevents arbitrary text diff --git a/server/internal/handler/property_test.go b/server/internal/handler/property_test.go index a73d7cd9ae9..137b2383cca 100644 --- a/server/internal/handler/property_test.go +++ b/server/internal/handler/property_test.go @@ -15,6 +15,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/multica-ai/multica/server/internal/issueproperty" "github.com/multica-ai/multica/server/internal/testutil" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -313,6 +314,30 @@ func TestIssuePropertyValues(t *testing.T) { t.Fatalf("good number: expected 200, got %d: %s", w.Code, w.Body.String()) } + // multi_text: duplicates dropped, caller order kept. + listText := createTestProperty(t, map[string]any{"name": "Aliases" + uuid.NewString()[:8], "type": "multi_text"}) + wl := setIssuePropertyRaw(t, issueID, listText.ID, []string{"beta", "alpha", "beta"}) + if wl.Code != http.StatusOK { + t.Fatalf("multi_text set: expected 200, got %d: %s", wl.Code, wl.Body.String()) + } + var listResp struct { + Properties map[string]any `json:"properties"` + } + json.NewDecoder(wl.Body).Decode(&listResp) + storedList, _ := listResp.Properties[listText.ID].([]any) + if len(storedList) != 2 || storedList[0] != "beta" || storedList[1] != "alpha" { + t.Fatalf("multi_text not stored in caller order: %v", storedList) + } + + // multi_url: entry-level http(s) validation. + listURL := createTestProperty(t, map[string]any{"name": "Links" + uuid.NewString()[:8], "type": "multi_url"}) + if w := setIssuePropertyRaw(t, issueID, listURL.ID, []string{"https://example.com/a", "https://example.com/b"}); w.Code != http.StatusOK { + t.Fatalf("good multi_url: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if w := setIssuePropertyRaw(t, issueID, listURL.ID, []string{"https://example.com/a", "ftp://example.com"}); w.Code != http.StatusBadRequest { + t.Fatalf("non-http(s) multi_url entry: expected 400, got %d", w.Code) + } + // Archived definitions reject new values but allow unset. warch := httptest.NewRecorder() req := newRequest("PATCH", "/api/properties/"+sel.ID, map[string]any{"archived": true}) @@ -362,6 +387,52 @@ func TestValidatePropertyValueUnit(t *testing.T) { } } +func TestValidatePropertyListValuesUnit(t *testing.T) { + validate := func(def db.IssueProperty, raw string) ([]byte, error) { + return issueproperty.ValidateValue(def, json.RawMessage(raw)) + } + textList := makePropertyDef("multi_text", nil) + if _, err := validate(textList, `[]`); err == nil { + t.Fatalf("empty multi_text array accepted") + } + if _, err := validate(textList, `"alpha"`); err == nil { + t.Fatalf("bare string into multi_text accepted") + } + if _, err := validate(textList, `["alpha", 3]`); err == nil { + t.Fatalf("non-string multi_text entry accepted") + } + if _, err := validate(textList, `["alpha", " "]`); err == nil { + t.Fatalf("blank multi_text entry accepted") + } + stored, err := validate(textList, `["alpha", "beta", "alpha"]`) + if err != nil { + t.Fatalf("valid multi_text rejected: %v", err) + } + if string(stored) != `["alpha","beta"]` { + t.Fatalf("multi_text not deduped in caller order: %s", stored) + } + over := make([]string, issueproperty.MaxListValues+1) + for i := range over { + over[i] = fmt.Sprintf("v%d", i) + } + overRaw, _ := json.Marshal(over) + if _, err := validate(textList, string(overRaw)); err == nil { + t.Fatalf("over-cap multi_text accepted") + } + + urlList := makePropertyDef("multi_url", nil) + if _, err := validate(urlList, `["https://a.example", "javascript:alert(1)"]`); err == nil { + t.Fatalf("non-http(s) multi_url entry accepted") + } + stored, err = validate(urlList, `[" https://a.example/x ", "https://b.example", "https://a.example/x"]`) + if err != nil { + t.Fatalf("valid multi_url rejected: %v", err) + } + if string(stored) != `["https://a.example/x","https://b.example"]` { + t.Fatalf("multi_url not trimmed and deduped: %s", stored) + } +} + func TestValidatePropertyNameReserved(t *testing.T) { for _, name := range []string{"status", "Priority", "due date", "Due_Date", "START DATE", "labels"} { if _, err := validatePropertyName(name); err == nil { diff --git a/server/internal/handler/search_identifier_test.go b/server/internal/handler/search_identifier_test.go new file mode 100644 index 00000000000..8beb0fd4af8 --- /dev/null +++ b/server/internal/handler/search_identifier_test.go @@ -0,0 +1,97 @@ +package handler + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/google/uuid" + "github.com/multica-ai/multica/server/internal/testutil" +) + +func TestParseQueryNumber(t *testing.T) { + for _, tc := range []struct { + query string + want int + }{ + {"MUL-123", 123}, + {"mul-123", 123}, + {" 123 ", 123}, + {"V2-12", 12}, + {" v2-12 ", 12}, + {"A1-7", 7}, + {"a1b2-42", 42}, + {"12-3", 0}, + {"2FAS-1", 0}, + {"V2-0", 0}, + {"V2--12", 0}, + {"V2-12x", 0}, + {"V_2-12", 0}, + {"MUL-", 0}, + {"search", 0}, + {"0", 0}, + {"", 0}, + } { + t.Run(tc.query, func(t *testing.T) { + got, ok := parseQueryNumber(tc.query) + if got != tc.want || ok != (tc.want > 0) { + t.Fatalf("parseQueryNumber(%q) = (%d, %t), want (%d, %t)", tc.query, got, ok, tc.want, tc.want > 0) + } + }) + } +} + +func TestIssueSearch_DigitPrefix(t *testing.T) { + workspaceID := dbfx.Workspace(t, "Digit prefix search", "digit-search-"+uuid.NewString(), testutil.Cols{"issue_prefix": "V2"}) + dbfx.Member(t, workspaceID, testUserID, "owner") + targetID := dbfx.Issue(t, "Abandoned plan", testutil.Cols{ + "workspace_id": workspaceID, "number": 12, "status": "cancelled", + }) + liveID := dbfx.Issue(t, "Notes about V2-12", testutil.Cols{"workspace_id": workspaceID}) + foreignWorkspaceID := dbfx.Workspace(t, "Other search workspace", "other-search-"+uuid.NewString()) + dbfx.Issue(t, "V2-12", testutil.Cols{"workspace_id": foreignWorkspaceID, "number": 12}) + + for _, query := range []string{"V2-12", "v2-12", "12"} { + t.Run(query, func(t *testing.T) { + // The fallback API must retain the exact cancelled hit ahead of live + // text matches, even when the result window has only one slot. + for _, includeClosed := range []bool{true, false} { + path := fmt.Sprintf("/api/issues/search?q=%s&include_closed=%t&limit=1", url.QueryEscape(query), includeClosed) + req := newRequest(http.MethodGet, path, nil) + req.Header.Set("X-Workspace-ID", workspaceID) + var response struct { + Issues []SearchIssueResponse `json:"issues"` + } + testutil.Call(t, testHandler.SearchIssues, req).Want(http.StatusOK).JSON(&response) + wantID := liveID + if includeClosed { + wantID = targetID + } + if len(response.Issues) != 1 || response.Issues[0].ID != wantID { + t.Fatalf("search include_closed=%t: got %+v, want issue %s", includeClosed, response.Issues, wantID) + } + } + + // List search uses the same parser while preserving active filters + // and the total for the filtered result set. + for _, status := range []string{"cancelled", "todo"} { + path := fmt.Sprintf("/api/issues?q=%s&status=%s&limit=1", url.QueryEscape(query), status) + req := newRequest(http.MethodGet, path, nil) + req.Header.Set("X-Workspace-ID", workspaceID) + var response struct { + Issues []IssueResponse `json:"issues"` + Total int64 `json:"total"` + } + testutil.Call(t, testHandler.ListIssues, req).Want(http.StatusOK).JSON(&response) + wantID := liveID + if status == "cancelled" { + wantID = targetID + } + if response.Total != 1 || len(response.Issues) != 1 || response.Issues[0].ID != wantID { + t.Fatalf("list status=%s: got %+v, want only issue %s", status, response, wantID) + } + } + }) + } +} diff --git a/server/internal/issueproperty/value.go b/server/internal/issueproperty/value.go index 1fc3bc3b28f..42dc6d6af2d 100644 --- a/server/internal/issueproperty/value.go +++ b/server/internal/issueproperty/value.go @@ -25,6 +25,10 @@ const ( // MaxActorValues is exported so the handler's existing focused tests can // continue to pin the public multi-actor limit after validation moved here. MaxActorValues = 20 + // Free-form list values (multi_text / multi_url) are capped the same way: + // the whole properties bag shares one 16KB row budget, and URL entries can + // individually reach 2048 bytes. + MaxListValues = 20 ) var actorKinds = []string{"member"} @@ -160,6 +164,63 @@ func optionsHint(config propertyConfig) string { return strings.Join(parts, ", ") } +// textItem validates one text value - a single `text` value or one +// `multi_text` element. Text keeps interior spacing as written. +func textItem(s string) (string, error) { + if strings.TrimSpace(s) == "" { + return "", errors.New("value cannot be empty (use DELETE to unset a property)") + } + if utf8.RuneCountInString(s) > maxTextValueLen { + return "", fmt.Errorf("value must be %d characters or fewer", maxTextValueLen) + } + return util.SanitizeTextForPostgres(s), nil +} + +// urlItem validates and canonicalizes one http(s) URL value - a single `url` +// value or one `multi_url` element. +func urlItem(s string) (string, error) { + s = strings.TrimSpace(s) + if len(s) > maxURLValueLen { + return "", fmt.Errorf("value must be %d characters or fewer", maxURLValueLen) + } + parsed, err := url.Parse(s) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", errors.New("value must be an http(s) URL") + } + return s, nil +} + +// validateStringList validates a multi_text / multi_url value: a non-empty +// array whose every element passes the item validator. Duplicates are dropped +// and the caller's order preserved, mirroring multi_actor. +func validateStringList(value any, itemValidator func(string) (string, error)) ([]byte, error) { + items, ok := value.([]any) + if !ok || len(items) == 0 { + return nil, errors.New("value must be a non-empty array of strings") + } + if len(items) > MaxListValues { + return nil, fmt.Errorf("value cannot list more than %d entries", MaxListValues) + } + seen := make(map[string]struct{}, len(items)) + out := make([]string, 0, len(items)) + for _, item := range items { + text, ok := item.(string) + if !ok { + return nil, errors.New("value must be a non-empty array of strings") + } + canonical, err := itemValidator(text) + if err != nil { + return nil, err + } + if _, duplicate := seen[canonical]; duplicate { + continue + } + seen[canonical] = struct{}{} + out = append(out, canonical) + } + return json.Marshal(out) +} + // ValidateValue checks a raw JSON value against the definition's type and // returns the canonical JSON stored by every issue-property write path. func ValidateValue(def db.IssueProperty, raw json.RawMessage) ([]byte, error) { @@ -181,27 +242,25 @@ func ValidateValue(def db.IssueProperty, raw json.RawMessage) ([]byte, error) { if !ok { return nil, errors.New("value must be a string") } - if strings.TrimSpace(text) == "" { - return nil, errors.New("value cannot be empty (use DELETE to unset a property)") - } - if utf8.RuneCountInString(text) > maxTextValueLen { - return nil, fmt.Errorf("value must be %d characters or fewer", maxTextValueLen) + item, err := textItem(text) + if err != nil { + return nil, err } - return json.Marshal(util.SanitizeTextForPostgres(text)) + return json.Marshal(item) + case "multi_text": + return validateStringList(value, textItem) case "url": text, ok := value.(string) if !ok { return nil, errors.New("value must be a URL string") } - text = strings.TrimSpace(text) - if len(text) > maxURLValueLen { - return nil, fmt.Errorf("value must be %d characters or fewer", maxURLValueLen) - } - parsed, err := url.Parse(text) - if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { - return nil, errors.New("value must be an http(s) URL") + item, err := urlItem(text) + if err != nil { + return nil, err } - return json.Marshal(text) + return json.Marshal(item) + case "multi_url": + return validateStringList(value, urlItem) case "number": if _, ok := value.(float64); !ok { return nil, errors.New("value must be a number") diff --git a/server/internal/service/builtin_skills/multica-platform/references/issues.md b/server/internal/service/builtin_skills/multica-platform/references/issues.md index 29a40ceab66..d460e3bcfde 100644 --- a/server/internal/service/builtin_skills/multica-platform/references/issues.md +++ b/server/internal/service/builtin_skills/multica-platform/references/issues.md @@ -128,8 +128,8 @@ concurrent edits. There is no CLI bulk-export or `--all` mode. Workspaces may define custom issue properties (Severity, Environment, QA Status, Reviewer, ...). They are the place for durable, typed issue state: values are validated against the definition (select options, date format, -http(s) URL, member reference), visible in the issue sidebar, and addressed -by name. +http(s) URL, member reference, free-form text/URL lists), visible in the +issue sidebar, and addressed by name. - Read what exists before writing: `multica property list` shows the catalog; `multica issue property list ` shows values set on the issue. @@ -147,6 +147,14 @@ multica issue property unset --name Environment workspace members only. `--value` takes a member name, email, UUID, short id, or an explicit `member:`; `multi_actor` takes a comma-separated list (duplicates dropped, order kept, max 20). +- `multi_text` / `multi_url` properties hold free-form lists: `--value` is a + comma-separated list of strings / http(s) URLs (empty entries skipped, + duplicates dropped, order kept, max 20). They take no options. An entry that + itself contains a comma cannot survive that form — pass a JSON array instead: + `--value '["Smith, John","https://en.wikipedia.org/wiki/Washington,_D.C."]'`. + The array form is always safe; use it whenever any entry has a comma or the + input starts with `[`, for example `--value '["[draft] spec"]'`. The same + forms work during creation: `--property 'Aliases=["Smith, John","[draft] spec"]'`. - Definitions may include an optional catalog icon for visual identification; it does not change the property's type or value validation. - Agents cannot create or edit property definitions (owner/admin humans only). @@ -166,7 +174,8 @@ multica issue list --sort property:Impact --direction desc --output json matches ANY of its values; different properties must ALL match. Values are option names or ids (select types), `true`/`false` (checkbox), a member name/email/id (actor types), or the value itself for text, url, number, - and date (`YYYY-MM-DD`). The reserved value `__none__` matches + and date (`YYYY-MM-DD`); for `multi_text` / `multi_url` the value matches + any single element of the list exactly. The reserved value `__none__` matches issues where the property is unset (works for every type; it is not index-backed, so use it for targeted audits rather than as a default listing filter). Only `=` is supported today; the `>=`, `<=` and `!=` @@ -175,7 +184,7 @@ multica issue list --sort property:Impact --direction desc --output json an ordinal scale (Low < Medium < High) sorts by meaning — and number/date/ text/url by value; issues without the property sort last either way. Archived properties and types without an order (multi_select, checkbox, - actor kinds) are rejected up front. + actor kinds, list types) are rejected up front. - `issue list` and `issue get` return `properties` as a map of definition id to stored value. Add `--resolve-properties` in JSON mode to get the rows `issue property list` prints instead (name, type, stored value, display @@ -187,8 +196,9 @@ multica issue list --status in_progress --output json --resolve-properties multica issue get --resolve-properties ``` - Read `display` for a single value and `display_values` for a multi_select - or multi_actor value; `value` keeps the stored ids. + Read `display` for a single value and `display_values` for a multi-value + property (multi_select, multi_actor, multi_text, multi_url); `value` keeps + the stored ids / strings. ## Status changes have server side effects diff --git a/server/migrations/564_issue_property_list_types.down.sql b/server/migrations/564_issue_property_list_types.down.sql new file mode 100644 index 00000000000..f05cb4ab9a3 --- /dev/null +++ b/server/migrations/564_issue_property_list_types.down.sql @@ -0,0 +1,26 @@ +-- Restore the pre-list type allowlist. +-- +-- Fails closed when any multi_text / multi_url definition still exists, the +-- same stance as 341's down: rewriting or deleting those rows would destroy +-- user data keyed by definition id. Archived definitions count too; their +-- values stay resolvable. Convert the definitions (and the issue values keyed +-- to them) to a pre-list type before rolling back. +DO $$ +DECLARE + list_defs BIGINT; +BEGIN + SELECT count(*) INTO list_defs + FROM issue_property + WHERE type IN ('multi_text', 'multi_url'); + + IF list_defs > 0 THEN + RAISE EXCEPTION 'cannot roll back 564: % multi_text/multi_url property definition(s) still exist', list_defs + USING HINT = 'Convert those definitions and the issue values keyed to them to a pre-list type first; this migration will not delete user data.'; + END IF; +END +$$; + +ALTER TABLE issue_property DROP CONSTRAINT IF EXISTS issue_property_type_check; +ALTER TABLE issue_property ADD CONSTRAINT issue_property_type_check + CHECK (type IN ('text', 'number', 'select', 'multi_select', 'date', 'checkbox', 'url', 'actor', 'multi_actor')) NOT VALID; +ALTER TABLE issue_property VALIDATE CONSTRAINT issue_property_type_check; diff --git a/server/migrations/564_issue_property_list_types.up.sql b/server/migrations/564_issue_property_list_types.up.sql new file mode 100644 index 00000000000..8aa37cac260 --- /dev/null +++ b/server/migrations/564_issue_property_list_types.up.sql @@ -0,0 +1,18 @@ +-- Custom issue property list types: multi_text / multi_url. +-- +-- Adds 'multi_text' and 'multi_url' to the type allowlist. Both store an +-- array of free-form strings (text entries or http(s) URLs) in insertion +-- order; validation, dedup, and the entry cap live in the handler, which is +-- also where each element is validated exactly like a single text / url +-- value. This constraint is only the outer guard. +-- +-- Like the actor types (341), the values ride the existing properties jsonb +-- bag: the @> containment filter for element-equality, the jsonb_path_ops GIN +-- index, and the client value schema all keep working unchanged. +-- +-- NOT VALID + VALIDATE keeps the ACCESS EXCLUSIVE lock instantaneous; +-- existing rows cannot carry the new types, so validation is a formality. +ALTER TABLE issue_property DROP CONSTRAINT IF EXISTS issue_property_type_check; +ALTER TABLE issue_property ADD CONSTRAINT issue_property_type_check + CHECK (type IN ('text', 'number', 'select', 'multi_select', 'date', 'checkbox', 'url', 'actor', 'multi_actor', 'multi_text', 'multi_url')) NOT VALID; +ALTER TABLE issue_property VALIDATE CONSTRAINT issue_property_type_check; diff --git a/server/pkg/agent/antigravity.go b/server/pkg/agent/antigravity.go index 49cfb2f94eb..11bd2a8acbc 100644 --- a/server/pkg/agent/antigravity.go +++ b/server/pkg/agent/antigravity.go @@ -185,8 +185,44 @@ type antigravityStreamEvent struct { Result *antigravityStreamResult `json:"result"` } +// antigravityNetworkIssueError is the provider sentence some agy releases +// report when a trailing round trip fails. It carries no Go error text, so it +// has to stay a literal match alongside the transport patterns below. const antigravityNetworkIssueError = "There was a network issue connecting to the server, please try again." +// antigravityTransportErrorRe matches the causes Go's http client reports when +// a round trip never produced a response: socket, DNS and TLS failures. These +// are the strings that appear inside the `*url.Error` agy wraps as +// `API error (attempt N): request failed: Post "...": `. +// +// Two things are deliberately excluded. +// +// - agy's own `request failed:` prefix. It is tempting to match it directly +// since it marks an http.Client failure, but only one spelling has been +// observed in the field, and nothing rules out agy reusing the same prefix +// for an HTTP status error. Matching the cause keeps a provider rejection +// from being read as transport noise. +// - Provider-side rejections: quota, capacity, overload, policy and auth all +// arrive as an HTTP response, so they are decisions about the request +// rather than a failure to deliver it. Those must stay failures the user +// sees instead of being smoothed over by a complete-looking answer — +// reportTaskResult documents failing closed for exactly that reason. +var antigravityTransportErrorRe = regexp.MustCompile(`(?i)(\bEOF\b|connection reset by peer|broken pipe|connection refused|connection timed out|i/o timeout|tls handshake timeout|tls: handshake failure|use of closed network connection|network is unreachable|no such host|server misbehaving|malformed HTTP response|http2: client connection lost|http2: server sent GOAWAY)`) + +// antigravityTrailingTransportError reports whether agy's provider error +// describes a transport-level failure rather than a decision the provider made +// about the request. +func antigravityTrailingTransportError(providerError string) bool { + trimmed := strings.TrimSpace(providerError) + if trimmed == "" { + return false + } + if strings.EqualFold(trimmed, antigravityNetworkIssueError) { + return true + } + return antigravityTransportErrorRe.MatchString(trimmed) +} + func (u antigravityStreamUsage) hasTokens() bool { return u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 } @@ -231,8 +267,15 @@ func antigravityResultStatus(status string) string { } } +// antigravityCompletedDespiteTrailingNetworkError reports whether a turn that +// agy ended in an error actually delivered a finished answer first. All three +// conditions are required: the trailing failure has to be transport-level (a +// provider-side rejection is a real failure), agy has to have handed back a +// non-empty canonical response, and the latest agent_response step has to have +// reached DONE — an ACTIVE step means the answer was still being written when +// the connection went away. func antigravityCompletedDespiteTrailingNetworkError(providerError, response string, agentResponseDone bool) bool { - return strings.EqualFold(strings.TrimSpace(providerError), antigravityNetworkIssueError) && + return antigravityTrailingTransportError(providerError) && strings.TrimSpace(response) != "" && agentResponseDone } diff --git a/server/pkg/agent/antigravity_test.go b/server/pkg/agent/antigravity_test.go index 0fc5cd04d3b..b5527489634 100644 --- a/server/pkg/agent/antigravity_test.go +++ b/server/pkg/agent/antigravity_test.go @@ -845,6 +845,33 @@ exit 1 ` } +// fakeAgyTrailingTransportErrorScript reproduces the real agy 1.2.13/1.2.14 +// sequence observed on 2026-09-30: the agent emits a complete DONE reply, agy +// then makes one more streamGenerateContent call whose round trip never +// produces a response, and reports the exhausted retries as +// `API error (attempt N): request failed: Post "...": EOF`. This is the same +// shape as the trailing network error above — a finished answer followed by a +// transport failure — spelled differently, so it must be preserved too. +func fakeAgyTrailingTransportErrorScript() string { + return `#!/bin/sh +printf '%s\n' '{"event":"step_update","step_update":{"conversation_id":"67a6d8f2-8523-46fc-8fc9-87e630cbe295","step_index":1,"state":"DONE","step_type":"agent_response","text_delta":"Complete answer before the transport failure."}}' +printf '%s\n' '{"event":"result","result":{"conversation_id":"67a6d8f2-8523-46fc-8fc9-87e630cbe295","status":"ERROR","response":"Complete answer before the transport failure.","error":"API error (attempt 3): request failed: Post \"https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse\": EOF"}}' +exit 1 +` +} + +// fakeAgyTrailingNonTransportErrorScript is the negative control for the above: +// the answer is complete and DONE, but the trailing failure is a provider-side +// quota/capacity error rather than a transport one. That is a real failure the +// user must see, so it must stay failed. +func fakeAgyTrailingNonTransportErrorScript() string { + return `#!/bin/sh +printf '%s\n' '{"event":"step_update","step_update":{"conversation_id":"77a6d8f2-8523-46fc-8fc9-87e630cbe295","step_index":1,"state":"DONE","step_type":"agent_response","text_delta":"Answer produced before quota ran out."}}' +printf '%s\n' '{"event":"result","result":{"conversation_id":"77a6d8f2-8523-46fc-8fc9-87e630cbe295","status":"ERROR","response":"Answer produced before quota ran out.","error":"API error (attempt 3): model capacity exhausted, retry later"}}' +exit 1 +` +} + // fakeAgyTrailingNetworkErrorAfterNewPartialResponseScript guards against a // completed earlier answer making a later, interrupted answer look complete. func fakeAgyTrailingNetworkErrorAfterNewPartialResponseScript() string { @@ -964,6 +991,181 @@ func TestAntigravityBackendIgnoresStaleActiveStepAfterDoneResponse(t *testing.T) } } +// A complete DONE answer followed by an exhausted-retry transport error is a +// finished turn. agy spells such a failure `API error (attempt N): request +// failed: Post "...": EOF` rather than the network-issue sentence, and matching +// only the latter discarded an answer that had already been produced and +// streamed (2026-09-30: every turn of one conversation reported +// blocked/agent_error.unknown while the reply sat in result.response). +func TestAntigravityBackendIgnoresTrailingTransportErrorAfterDoneResponse(t *testing.T) { + t.Parallel() + + fakePath := filepath.Join(t.TempDir(), "agy") + writeTestExecutable(t, fakePath, []byte(fakeAgyTrailingTransportErrorScript())) + + backend, err := New("antigravity", Config{ExecutablePath: fakePath, Logger: quietAntigravityLogger()}) + if err != nil { + t.Fatalf("new antigravity backend: %v", err) + } + session, err := backend.Execute(context.Background(), "prompt-ignored", ExecOptions{}) + if err != nil { + t.Fatalf("execute: %v", err) + } + for range session.Messages { + } + result, ok := <-session.Result + if !ok { + t.Fatal("result channel closed without a value") + } + if result.Status != "completed" || result.Output != "Complete answer before the transport failure." || result.Error != "" { + t.Fatalf("result = status %q output %q error %q", result.Status, result.Output, result.Error) + } +} + +// The negative control: a complete DONE answer does not on its own make a turn +// successful. When the trailing failure is provider-side (quota, capacity, +// policy, auth) rather than transport, the turn must stay failed so the user +// sees the real cause instead of an answer presented as a finished result. +func TestAntigravityBackendKeepsNonTransportFailureAfterDoneResponse(t *testing.T) { + t.Parallel() + + fakePath := filepath.Join(t.TempDir(), "agy") + writeTestExecutable(t, fakePath, []byte(fakeAgyTrailingNonTransportErrorScript())) + + backend, err := New("antigravity", Config{ExecutablePath: fakePath, Logger: quietAntigravityLogger()}) + if err != nil { + t.Fatalf("new antigravity backend: %v", err) + } + session, err := backend.Execute(context.Background(), "prompt-ignored", ExecOptions{}) + if err != nil { + t.Fatalf("execute: %v", err) + } + for range session.Messages { + } + result, ok := <-session.Result + if !ok { + t.Fatal("result channel closed without a value") + } + if result.Status != "failed" || result.Output != "Answer produced before quota ran out." || !strings.Contains(result.Error, "model capacity exhausted") { + t.Fatalf("result = status %q output %q error %q", result.Status, result.Output, result.Error) + } +} + +// TestAntigravityTrailingTransportErrorClassification pins both edges of the +// classifier. The "want true" half is the field-observed spelling plus the +// other causes Go's http client reports for a round trip that never produced a +// response. The "want false" half is the one that matters: a provider +// rejection that happens to travel inside agy's `request failed:` wrapper must +// stay a failure, otherwise a quota or overload error would be laundered into a +// successful turn just because the model had already emitted some text. +func TestAntigravityTrailingTransportErrorClassification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err string + want bool + }{ + { + name: "field observed EOF after exhausted retries", + err: `API error (attempt 3): request failed: Post "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse": EOF`, + want: true, + }, + { + name: "legacy provider sentence", + err: antigravityNetworkIssueError, + want: true, + }, + { + name: "legacy provider sentence in other case and padding", + err: " THERE WAS A NETWORK ISSUE CONNECTING TO THE SERVER, PLEASE TRY AGAIN. ", + want: true, + }, + {name: "unexpected EOF", err: `Post "https://host/v1": unexpected EOF`, want: true}, + {name: "connection reset", err: `read tcp 1.2.3.4:443: connection reset by peer`, want: true}, + {name: "broken pipe", err: `write tcp 1.2.3.4:443: broken pipe`, want: true}, + {name: "connection refused", err: `dial tcp 1.2.3.4:443: connection refused`, want: true}, + {name: "socket i/o timeout", err: `read tcp 1.2.3.4:443: i/o timeout`, want: true}, + {name: "tls handshake timeout", err: `net/http: TLS handshake timeout`, want: true}, + {name: "tls handshake failure", err: `remote error: tls: handshake failure`, want: true}, + {name: "closed connection", err: `use of closed network connection`, want: true}, + {name: "dns no such host", err: `dial tcp: lookup host: no such host`, want: true}, + {name: "dns server misbehaving", err: `lookup host: server misbehaving`, want: true}, + {name: "network unreachable", err: `connect: network is unreachable`, want: true}, + {name: "malformed response", err: `malformed HTTP response`, want: true}, + {name: "http2 connection lost", err: `http2: client connection lost`, want: true}, + {name: "http2 goaway", err: `http2: server sent GOAWAY and closed the connection`, want: true}, + + {name: "empty", err: "", want: false}, + {name: "whitespace only", err: " ", want: false}, + { + name: "quota rejection inside the request-failed wrapper", + err: `API error (attempt 3): request failed: 429 Too Many Requests`, + want: false, + }, + { + name: "status error inside the request-failed wrapper", + err: `API error (attempt 3): request failed: unexpected status code 503`, + want: false, + }, + {name: "model capacity exhausted", err: `API error (attempt 3): model capacity exhausted, retry later`, want: false}, + {name: "prefill queue overloaded", err: `PREFILL_QUEUE_OVERLOADED: Overloaded`, want: false}, + {name: "resource exhausted", err: `RESOURCE_EXHAUSTED: Quota exceeded for quota group`, want: false}, + {name: "safety policy", err: `The request was rejected by the safety filter`, want: false}, + {name: "auth", err: `unauthenticated: invalid API key`, want: false}, + {name: "invalid argument", err: `INVALID_ARGUMENT: request contains an invalid argument`, want: false}, + {name: "permission denied", err: `permission denied`, want: false}, + {name: "tool crash", err: `agent executor error: tool crashed`, want: false}, + {name: "bare status text", err: `agy returned status ERROR`, want: false}, + {name: "cancelled", err: `execution cancelled`, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := antigravityTrailingTransportError(tc.err); got != tc.want { + t.Fatalf("antigravityTrailingTransportError(%q) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +// The classifier must not fire on a complete answer alone. Each of these has a +// non-empty response and a DONE agent_response step, so the error class is the +// only thing standing between a preserved answer and a laundered failure. +func TestAntigravityCompletedDespiteTrailingNetworkErrorRequiresAllThree(t *testing.T) { + t.Parallel() + + const transport = `API error (attempt 3): request failed: Post "https://host/v1": EOF` + const quota = `API error (attempt 3): model capacity exhausted` + + tests := []struct { + name string + err string + response string + agentResponseDone bool + want bool + }{ + {name: "all three hold", err: transport, response: "answer", agentResponseDone: true, want: true}, + {name: "response whitespace only", err: transport, response: " \n ", agentResponseDone: true, want: false}, + {name: "response empty", err: transport, response: "", agentResponseDone: true, want: false}, + {name: "answer still being written", err: transport, response: "partial", agentResponseDone: false, want: false}, + {name: "provider rejection", err: quota, response: "answer", agentResponseDone: true, want: false}, + {name: "no error at all", err: "", response: "answer", agentResponseDone: true, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := antigravityCompletedDespiteTrailingNetworkError(tc.err, tc.response, tc.agentResponseDone) + if got != tc.want { + t.Fatalf("antigravityCompletedDespiteTrailingNetworkError(%q, %q, %v) = %v, want %v", + tc.err, tc.response, tc.agentResponseDone, got, tc.want) + } + }) + } +} + func TestAntigravityBackendKeepsNetworkFailureForPartialResponse(t *testing.T) { t.Parallel() diff --git a/server/pkg/agent/zeroclaw_test.go b/server/pkg/agent/zeroclaw_test.go index b8d7d980772..16db5852ce4 100644 --- a/server/pkg/agent/zeroclaw_test.go +++ b/server/pkg/agent/zeroclaw_test.go @@ -73,9 +73,7 @@ func writeFakeZeroclawScript(t *testing.T, script string) string { t.Helper() dir := t.TempDir() bin := filepath.Join(dir, "zeroclaw") - if err := os.WriteFile(bin, []byte(script), 0755); err != nil { - t.Fatalf("write fake zeroclaw: %v", err) - } + writeTestExecutable(t, bin, []byte(script)) return bin }