diff --git a/.env.example b/.env.example index 65211abc9e9..0868943f29f 100644 --- a/.env.example +++ b/.env.example @@ -167,6 +167,20 @@ MULTICA_LLM_DEFAULT_MODEL= # other 4xx. This budget does not cover a stream that breaks mid-response, # and it is unrelated to task-level retries. MULTICA_LLM_MAX_RETRIES= +# - Ask an OpenAI-compatible gateway to turn the model's reasoning +# ("thinking") off by adding chat_template_kwargs: {"enable_thinking": +# false} to every request body this layer sends. Some gateways and +# GLM/Qwen-style model routes honor the field; there it removes the +# thinking pass that would otherwise dominate the latency budget of the +# assist calls above. Standard OpenAI endpoints reject unknown body +# fields, so only enable this when the upstream accepts it. GPT-5.6-family +# models already get reasoning_effort=none on the follow-up questions +# request. Chat auto-titling sends no reasoning field; this switch affects +# it only when the configured upstream accepts chat_template_kwargs. +# Accepted values are true/false and 1/0 +# (case-insensitive); anything else fails the boot. The startup log +# ("llm retry policy") reports the effective state. +MULTICA_LLM_DISABLE_THINKING= MULTICA_DAEMON_CONFIG= MULTICA_WORKSPACE_ID= MULTICA_DAEMON_ID= @@ -623,6 +637,19 @@ MULTICA_LARK_WS_PROXY_URL= # Generate one with: openssl rand -base64 32 MULTICA_DINGTALK_SECRET_KEY= +# Slack bot integration (Settings → Integrations → Slack) +# Off until MULTICA_SLACK_SECRET_KEY is set — a base64-encoded 32-byte key +# that encrypts each installation's bot and app tokens at rest. Leave empty +# to disable. Generate one with: openssl rand -base64 32 +MULTICA_SLACK_SECRET_KEY= + +# Telegram bot integration (Settings → Integrations → Telegram) +# Off until MULTICA_TELEGRAM_SECRET_KEY is set — a base64-encoded 32-byte key +# that encrypts each Bot's token at rest. Leave empty to disable. Keep it +# stable: losing or rotating it makes stored Bot tokens unreadable. +# Generate one with: openssl rand -base64 32 +MULTICA_TELEGRAM_SECRET_KEY= + # Frontend # Leave empty — auto-derived from page origin in browser, set by Makefile for local dev. # NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set. @@ -640,16 +667,18 @@ NEXT_PUBLIC_WS_URL= # REMOTE_API_URL=https://multica-api.copilothub.ai # ==================== Self-hosting: Control Signups (fixes #930) ==================== -# Set to "false" to completely disable new user signups (recommended for private instances) +# Set to "false" to restrict new accounts to allowlisted or invited users. +# A pending, unexpired workspace invitation also permits emails outside either +# allowlist when ALLOW_SIGNUP=true. Revocation does not remove existing accounts. ALLOW_SIGNUP=true # The web UI reads ALLOW_SIGNUP from /api/config at runtime, so toggling this # only requires restarting the backend / compose stack — not rebuilding web. # It is not hot-reloaded. -# Optional: Only allow emails from these domains (comma-separated) +# Optional: Allow emails from these domains without an invitation (comma-separated) ALLOWED_EMAIL_DOMAINS= -# Optional: Only allow these exact email addresses (comma-separated) +# Optional: Allow these exact email addresses without an invitation (comma-separated) ALLOWED_EMAILS= # Set to "true" to disable workspace creation for every caller on this diff --git a/.github/ci-paths.json b/.github/ci-paths.json index 03368772da0..4e7a6abda5c 100644 --- a/.github/ci-paths.json +++ b/.github/ci-paths.json @@ -102,6 +102,7 @@ "Makefile", "server/internal/handler/reserved_slugs.json", "packages/core/paths/reserved-slugs.ts", + "server/cmd/server/router.go", ".github/workflows/ci.yml", ".github/ci-paths.json", "scripts/ci-scope.mjs", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33ae440aba6..d398907b2f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -282,6 +282,14 @@ jobs: # release, an entry built on the old one must not be restored. setup-go # keys on ImageOS for the same reason (actions/setup-go#368). # + # A rolling entry only grows on its own: each save is the restored entry + # plus this commit's objects, and Go trims only what has sat unused for + # five days, so the entry carried every recent main commit's race objects. + # It reached 3.5 GB in ten days, and restoring and saving it cost more than + # the compile it saved (MUL-7547). Before saving, the job therefore drops + # every entry this run did not use, leaving the next run the objects of the + # tree it is most likely to build. + # # This job is the only writer of either entry. backend-agent-tests restores # both, but it downloads just pkg/agent's slice of the module graph and # finishes first, so letting it save would repeat the first-writer-wins @@ -365,6 +373,14 @@ jobs: key: ${{ steps.gocache.outputs.prefix }}${{ github.sha }} restore-keys: ${{ steps.gocache.outputs.prefix }} + # Go refreshes a cache entry's mtime when it uses it, but only once that + # mtime is an hour old. So after the run, an entry no newer than an hour + # before the first compile is one this run did not use. The prune step + # before the save compares against this marker. + - name: Mark Go build cache epoch + id: goepoch + run: touch -d '-1 hour' "$RUNNER_TEMP/go-build-epoch" + - name: Build run: cd server && go build -race ./... @@ -387,6 +403,17 @@ jobs: # pkg/agent runs on its own runner: backend-agent-tests below. run: bash scripts/test-go.sh --race --only regular + # Go's own trim (cmd/go/internal/cache) with this run as the window + # instead of five days: only `-a`/`-d` entries, and an executable entry + # is a directory, so it goes whole. See the job comment. + - name: Prune Go build cache to this run's entries + if: ${{ !cancelled() && github.event_name == 'push' && steps.goepoch.outcome == 'success' }} + run: | + du -sh ~/.cache/go-build + find ~/.cache/go-build -mindepth 2 -maxdepth 2 \( -name '*-a' -o -name '*-d' \) \ + ! -newer "$RUNNER_TEMP/go-build-epoch" -exec rm -rf {} + + du -sh ~/.cache/go-build + # Publishes from main only; see the job comment. `!cancelled()` so a red # main still refreshes the objects for the next run, and the key step's # outcome so a run that never reached Go does not save an empty entry @@ -609,6 +636,45 @@ jobs: # silently as "ok". run: go test ./internal/daemon/execenv -v -run '^TestWindowsOpenclawShim' -count=1 -timeout=5m + - name: Test Windows workdir containment guard in the CLI + if: ${{ needs.changes.outputs.backend == 'true' }} + working-directory: server + # The CLI's --content-file / --attachment guard decides whether a path is + # inside the task workdir by canonicalizing both sides. Two of the things + # that decision rests on are Windows-only and covered nowhere else: a + # candidate on another volume, where filepath.Rel returns an error rather + # than a "..", so the guard has to read that as "outside" instead of + # surfacing `Rel: can't make Z:\... relative to C:\...` as a resolve + # failure; and a directory junction — the containment-relevant link shape + # that needs no elevation, so the one real hosts have — which resolves to + # itself rather than to its target under this module's go directive. The + # junction test pins that gap deliberately, records the observations it + # rests on, and fails with instructions if the platform ever starts + # resolving them. + # cmd/multica has no Windows job otherwise, so these windows-tagged + # tests run nowhere else. It matters twice over on a non-admin host, + # where the package's cross-platform cases skip for want of the symlink + # privilege; the ubuntu backend job covers those shapes instead. + # -v so a silent skip (or a -run pattern that stops matching) is visible + # in the log instead of passing as "ok". + run: go test ./cmd/multica -v -run '^TestFileWithinWorkingDirWindows' -count=1 -timeout=5m + + - name: Test Windows link-target classification in internal/util + if: ${{ needs.changes.outputs.backend == 'true' }} + working-directory: server + # The guard's Windows correctness rests on internal/util's link-target + # grammar (classifyTarget), whose branch ordering is load-bearing: + # VolumeName is non-empty for drive-absolute paths too, so asking it + # before IsAbs misreads a junction's absolute target as drive-relative + # and resolves it against the working directory — exactly how a + # junction escape read as inside the workdir for one CI run. The + # fixture-free table pins every branch without needing a filesystem, + # but windows-tagged util tests run nowhere else. Scoped to the table + # by name; the package's symlinked-workdir cases belong to the ubuntu + # backend job, which runs the whole package. + # -v so a silent skip or a pattern that stops matching is visible. + run: go test ./internal/util -v -run '^TestClassifyTarget' -count=1 -timeout=5m + - name: Test Windows isolated repo checkout is committable working-directory: server # #6449: on Windows the daemon now hands Codex tasks a checkout whose @@ -670,7 +736,7 @@ jobs: # explicitly configured path reach the release executable. The same job # covers the npm shape, where the entry point is a `.cmd` shim that only # the command interpreter can run. - run: go test ./internal/daemon -v -run '^(TestCanonicalExecutablePath|TestTrimExtendedLengthPrefix|TestResolveAgentExecutablePathKeeps|TestResolveAgentEntry(FollowsRetargetedInstallerJunction|CanonicalizesRediscoveredJunction|ForLaunchKeepsCmdShimLaunchable|ForLaunchRejectsUnverifiedInitialJunctionTarget|ForLaunchRejectsRediscoveredJunctionWhenFinalPathResolutionFails|DoesNotSharePreRetargetSingleflightResult|ForLaunchFailsWhenJunctionKeepsRetargeting)|TestHandleTaskReportsWindowsCodexProcessStartFailure)' -count=1 -timeout=5m + run: go test ./internal/daemon -v -run '^(TestCanonicalExecutablePath|TestTrimExtendedLengthPrefix|TestResolveAgentExecutablePathKeeps|TestResolveAgentExecutablePath_ProfileOverride|TestResolveAgentEntry(FollowsRetargetedInstallerJunction|CanonicalizesRediscoveredJunction|ForLaunchKeepsCmdShimLaunchable|ForLaunchRejectsUnverifiedInitialJunctionTarget|ForLaunchRejectsRediscoveredJunctionWhenFinalPathResolutionFails|DoesNotSharePreRetargetSingleflightResult|ForLaunchFailsWhenJunctionKeepsRetargeting)|TestHandleTaskReportsWindowsCodexProcessStartFailure)' -count=1 -timeout=5m - name: Build Windows CLI helper entrypoint working-directory: server @@ -777,7 +843,12 @@ jobs: if: runner.os != 'Windows' run: bash scripts/install.test.sh - - name: Test PowerShell installer + - name: Test installer with Windows PowerShell 5.1 + if: runner.os == 'Windows' + shell: powershell + run: ./scripts/install.ps1.test.ps1 + + - name: Test installer with PowerShell 7 if: runner.os == 'Windows' shell: pwsh run: ./scripts/install.ps1.test.ps1 diff --git a/.github/workflows/coderpush-images.yml b/.github/workflows/coderpush-images.yml new file mode 100644 index 00000000000..b9f6e8b39d0 --- /dev/null +++ b/.github/workflows/coderpush-images.yml @@ -0,0 +1,52 @@ +name: CoderPush main images + +# Build immutable artifacts from main. Publishing images does not deploy them. +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: coderpush-images-${{ github.sha }} + cancel-in-progress: false + +jobs: + build: + if: github.repository == 'CoderPush/multica' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - image: multica-backend + dockerfile: Dockerfile + - image: multica-web + dockerfile: Dockerfile.web + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: true + tags: ghcr.io/coderpush/${{ matrix.image }}:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=https://github.com/CoderPush/multica + org.opencontainers.image.revision=${{ github.sha }} + build-args: | + VERSION=main-${{ github.sha }} + COMMIT=${{ github.sha }} + NEXT_PUBLIC_APP_VERSION=main-${{ github.sha }} + cache-from: type=gha,scope=${{ matrix.image }} + cache-to: type=gha,mode=max,scope=${{ matrix.image }} diff --git a/.gitignore b/.gitignore index 4449b08a13f..5cc0cef2bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,5 @@ data/ __pycache__/ *.pyc perf-report/ +# The production example is a tracked template; `.env*` above would hide it. +!apps/mobile/.env.production.example diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index b5cf40e00a5..bfaef20cdc2 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -31,7 +31,23 @@ For install script or manual installs, use: multica update ``` -`multica update` auto-detects your installation method and upgrades accordingly. +`multica update` uses GitHub Releases by default. Self-hosted installations can +point the CLI at a GitHub Releases-compatible metadata mirror and an artifact +mirror without changing the command. To let a daemon poll that source, also +enable self-update explicitly because self-hosted auto-update is disabled by +default: + +```bash +export MULTICA_RELEASE_API_BASE_URL=https://updates.example/api +export MULTICA_RELEASE_DOWNLOAD_BASE_URL=https://updates.example/releases/download +export MULTICA_DAEMON_AUTO_UPDATE=true +multica update +``` + +The metadata mirror must serve `/repos/multica-ai/multica/releases/latest` and +`/repos/multica-ai/multica/releases/tags/`. The artifact mirror must serve +`//` and preserve the published `checksums.txt` contents. +When these variables are unset, the GitHub defaults remain unchanged. ## Quick Start @@ -1101,3 +1117,11 @@ On the API, both endpoints accept `?include=content` and `?include=metadata`. A request that sends neither still gets `content`, on both endpoints, so a server upgrade never changes what an un-upgraded client receives — it is the CLI that asks for the smaller shape. + +### Custom runtime compatibility targets + +Create custom Oh-My-Pi profiles with `multica runtime profile create --runtime-type omp --command-name omp --display-name "Custom Oh-My-Pi"`. +The immutable `runtime_type` selects model discovery, skills paths, and launch behavior; +the server derives `protocol_family` (`pi` for `omp`). Custom command/path overrides and +fixed arguments still apply, and the runtime retains its custom-profile provenance. +Existing profiles and the legacy `--protocol-family` flag retain their original target. diff --git a/LICENSE b/LICENSE index 8e45780e1ad..9286fa5f8ec 100644 --- a/LICENSE +++ b/LICENSE @@ -12,6 +12,11 @@ together and what must be delivered when you redistribute. Part I — Additional Conditions +In this Multica License, "the producer" means Index Labs (Hong Kong) +Limited, the company that develops and distributes Multica. Commercial +licenses and branding waivers may be requested at +https://www.multica.ai/contact-sales. + 1. Multica may be utilized commercially, including as a backend service for other applications or as a task management platform for enterprises, subject to the following conditions: @@ -112,7 +117,7 @@ together and what must be delivered when you redistribute. deliver this complete file. Delivering Part II alone does not satisfy the Multica License or section 4(a) of Part II. -© 2025-2026 Multica, Inc. +© 2025-2026 Index Labs (Hong Kong) Limited ------------------------------------------------------------------------ diff --git a/NOTICE b/NOTICE index df91f120bbd..80abfee09c9 100644 --- a/NOTICE +++ b/NOTICE @@ -1,8 +1,8 @@ Multica -Copyright 2025-2026 Multica, Inc. +Copyright 2025-2026 Index Labs (Hong Kong) Limited -This product includes software developed at Multica, Inc. -(https://github.com/multica-ai/multica). +This product includes software developed at Index Labs (Hong Kong) Limited +for Multica (https://github.com/multica-ai/multica). Multica is distributed under the Multica License, which incorporates the complete text of the Apache License, Version 2.0 together with additional diff --git a/README.md b/README.md index 9ead12cc41c..835e2832fb9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **Agents that show up on the board.** -Multica is an open-source workspace where you assign work to AI coding agents the way you'd +Multica is a source-available workspace where you assign work to AI coding agents the way you'd assign it to a teammate — they pick up the issue, report progress, raise blockers, and hand it back for review. Self-hostable, works with 26 agent CLIs, no lock-in. diff --git a/README.zh.md b/README.zh.md index aa81161bb77..b6a69825a84 100644 --- a/README.zh.md +++ b/README.zh.md @@ -10,7 +10,7 @@ **智能体,也在看板上。** -Multica 是一个开源的团队工作区。你像给同事派活一样,把任务交给 AI 编码智能体——它自己接手、边做边 +Multica 是一个源码公开的团队工作区。你像给同事派活一样,把任务交给 AI 编码智能体——它自己接手、边做边 汇报、卡住了主动说,做完交回来给你审。可自部署,支持 26 种智能体 CLI,不绑定任何厂商。 [![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml) @@ -245,7 +245,7 @@ iOS 客户端在 [`apps/mobile/`](apps/mobile/),怎么编译装到自己 iPhon --- -## 开源协议 +## 许可协议 [Multica License](LICENSE) —— Apache License 2.0 全文并入,外加针对托管服务、商业嵌入和品牌标识的 附加条件。自部署、改代码、在它之上做东西都可以;准确条款以 [LICENSE](LICENSE) 为准,署名信息见 diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index fdb5623f437..8d666ac8467 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -263,6 +263,8 @@ When developing from a checkout, use the local chart path instead: helm install multica deploy/helm/multica -n multica ``` +If the backend has to reach services whose certificates come from an internal CA, such as a self-hosted Gitea, set `backend.extraCACerts.configMap`. See [Advanced Configuration → Private CA Certificates](SELF_HOSTING_ADVANCED.md#private-ca-certificates-optional). + Watch the pods come up: ```bash diff --git a/SELF_HOSTING_ADVANCED.md b/SELF_HOSTING_ADVANCED.md index 4e6258760b7..97db85239f0 100644 --- a/SELF_HOSTING_ADVANCED.md +++ b/SELF_HOSTING_ADVANCED.md @@ -71,7 +71,7 @@ Changes take effect after restarting the backend / compose stack. The web UI rea | Variable | Description | |----------|-------------| -| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance | +| `ALLOW_SIGNUP` | Set to `false` to restrict new accounts to allowlisted or invited users | | `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains | | `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses | | `DISABLE_WORKSPACE_CREATION` | Set to `true` to make `POST /api/workspaces` return 403 for every caller — users can only join workspaces they were invited to | @@ -80,14 +80,14 @@ Changes take effect after restarting the backend / compose stack. The web UI rea #### Locking down workspace creation -`ALLOW_SIGNUP=false` blocks new accounts from being created, but it does **not** block an already-signed-in user from creating another workspace via `POST /api/workspaces`. On a self-hosted instance where every issue/repo/agent must be visible to the platform admin, set `DISABLE_WORKSPACE_CREATION=true` to close that gap. The recommended bootstrap sequence is: +`ALLOW_SIGNUP=false` restricts new accounts to allowlisted or invited users, but it does **not** block an already-signed-in user from creating another workspace via `POST /api/workspaces`. On a self-hosted instance where every issue/repo/agent must be visible to the platform admin, set `DISABLE_WORKSPACE_CREATION=true` to close that gap. The recommended bootstrap sequence is: 1. Start the instance with `DISABLE_WORKSPACE_CREATION=false` (the default). 2. Sign in as the admin and create the shared workspace. -3. Set `DISABLE_WORKSPACE_CREATION=true` and restart the backend. Optionally set `ALLOW_SIGNUP=false` at the same time if you also want to block new account creation. +3. Set `DISABLE_WORKSPACE_CREATION=true` and restart the backend. Optionally set `ALLOW_SIGNUP=false` at the same time if you also want to restrict new account creation. 4. Going forward, additional users join via invitation only — the "Create workspace" affordance is hidden in the UI and any direct API call returns 403. -> Note: setting `ALLOW_SIGNUP=false` blocks **all** new account creation, including users who already have a pending invitation. If you need invited users to be able to sign up but not create their own workspaces, keep `ALLOW_SIGNUP=true` (optionally combined with `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS`) and only flip `DISABLE_WORKSPACE_CREATION=true`. +> Note: setting `ALLOW_SIGNUP=false` enables invite-only account creation. A new user with a live pending workspace invitation can create an account with the invited email; users without an allowlist match or a valid invitation remain blocked. Invitations also permit emails outside configured allowlists when `ALLOW_SIGNUP=true`. Revocation does not delete accounts already created using an invitation or prevent those accounts from signing in. Combine this with `DISABLE_WORKSPACE_CREATION=true` when invitees must join an existing workspace instead of creating their own. ### File Storage (Optional) @@ -177,6 +177,71 @@ rewrite configuration. Its backend fallback therefore accepts `BACKEND_PORT` → `API_PORT` → `SERVER_PORT` → `8080`, while an explicit `REMOTE_API_URL` or `NEXT_PUBLIC_API_URL` still takes priority. +### Private CA Certificates (Optional) + +The backend checks the TLS certificates of the HTTPS services it calls against +the system trust store in its image. A service whose certificate comes from an +internal CA — for example a [self-hosted Gitea, Forgejo, or GitLab](https://multica.ai/docs/vcs-integration) +— fails until the backend trusts that CA. Connecting such a Git provider reports +that its certificate is signed by a certificate authority the server does not +trust. + +Add the CA to the backend's trust store rather than turning verification off. +The backend is a Go program: on Linux it loads the system certificate bundle +plus every PEM file in the directories listed in `SSL_CERT_DIR`. Mount the CA +into its own read-only directory and list it after the system directory: + +``` +SSL_CERT_DIR=/etc/ssl/certs:/etc/multica/ca-certs +``` + +**Kubernetes (Helm):** create a ConfigMap whose keys are PEM files, then point +`backend.extraCACerts.configMap` at it. The chart mounts it at +`/etc/multica/ca-certs` and sets `SSL_CERT_DIR` as above. + +```bash +kubectl -n multica create configmap multica-extra-ca --from-file=internal-ca.crt +helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \ + --version -n multica --reuse-values \ + --set backend.extraCACerts.configMap=multica-extra-ca +``` + +**Docker Compose:** put the PEM files in a directory next to +`docker-compose.selfhost.yml` (here `./ca-certs`) and add an override file, +`docker-compose.ca.yml`: + +```yaml +services: + backend: + environment: + SSL_CERT_DIR: /etc/ssl/certs:/etc/multica/ca-certs + volumes: + - ./ca-certs:/etc/multica/ca-certs:ro +``` + +```bash +docker compose -f docker-compose.selfhost.yml -f docker-compose.ca.yml up -d backend +``` + +Pass both files every time you run a command that recreates the backend. A +command with only `docker-compose.selfhost.yml`, such as `make selfhost`, +recreates the backend without the CA. + +Things to know: + +- **The backend reads the CA only when it starts.** After you add or replace a + CA file, restart the backend: `kubectl -n multica rollout restart deploy/multica-backend`, + or `docker compose -f docker-compose.selfhost.yml -f docker-compose.ca.yml restart backend`. + With Compose, `up -d` is not enough here: the container's configuration has + not changed, so Compose keeps the running container. With Helm, a + `helm upgrade` also restarts the backend when the ConfigMap has changed. +- **The CA applies to the whole backend process**, not only the Git provider + integration: every outbound TLS client in the backend that uses the system + trust store trusts it too. +- **Only the trust problem is fixed.** An expired certificate, or one that does + not cover the host name in the URL, is still rejected; fix the certificate + itself. + ### WeCom frame tracing | Variable | Default | Description | diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c1722499d61..dc2e0e02a62 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@multica/desktop", "productName": "Multica", - "version": "0.1.0", + "version": "0.5.2", "private": true, "description": "Multica Desktop — native desktop client for the Multica platform.", "homepage": "https://multica.ai", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0d19e3a4f14..a2f7bbc7b99 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -27,6 +27,7 @@ import { type RendererRecoveryWindow, } from "./renderer-recovery"; import { createBestEffortDevLog } from "./dev-log"; +import { appendMissingPathDirs } from "./path-fallback"; import { writeFreezeBreadcrumb, readFreezeBreadcrumb, @@ -107,15 +108,17 @@ const BUNDLED_ICON_PATH = join(__dirname, "../../resources/icon.png").replace( // or any daemon-manager spawn. if (process.platform !== "win32") { fixPath(); - // Fallback: prepend common install locations in case fix-path came up - // short (broken shell rc, non-interactive $SHELL, missing entries). Safe - // to duplicate — PATH lookups short-circuit on first match. - const fallbackPaths = [ + // Fallback: ensure common install locations are on PATH when fix-path came + // up short (broken shell rc, non-interactive $SHELL, missing entries). + // Append only missing dirs — never prepend. Prepending /usr/local/bin over + // a recovered login PATH shadows nvm/fnm Node with a stale system binary + // (e.g. Node 12), which breaks shebang CLIs (`#!/usr/bin/env node`) such as + // CodeBuddy and OpenClaw during daemon --version probes. + process.env.PATH = appendMissingPathDirs(process.env.PATH ?? "", [ "/opt/homebrew/bin", "/usr/local/bin", join(homedir(), ".local/bin"), - ]; - process.env.PATH = `${fallbackPaths.join(":")}:${process.env.PATH ?? ""}`; + ]); } const PROTOCOL = "multica"; diff --git a/apps/desktop/src/main/path-fallback.test.ts b/apps/desktop/src/main/path-fallback.test.ts new file mode 100644 index 00000000000..4d07d5dacec --- /dev/null +++ b/apps/desktop/src/main/path-fallback.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { appendMissingPathDirs } from "./path-fallback"; + +describe("appendMissingPathDirs", () => { + const fallbacks = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/Users/me/.local/bin", + ]; + + it("appends only dirs that are not already present", () => { + const current = "/Users/me/.nvm/versions/node/v22.0.0/bin:/usr/bin"; + expect(appendMissingPathDirs(current, fallbacks)).toBe( + [ + "/Users/me/.nvm/versions/node/v22.0.0/bin", + "/usr/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + "/Users/me/.local/bin", + ].join(":"), + ); + }); + + it("leaves PATH unchanged when every fallback is already present", () => { + const current = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/Users/me/.local/bin", + "/usr/bin", + ].join(":"); + expect(appendMissingPathDirs(current, fallbacks)).toBe(current); + }); + + it("still adds every fallback when PATH is empty", () => { + expect(appendMissingPathDirs("", fallbacks)).toBe(fallbacks.join(":")); + }); + + it("never prepends — recovered nvm Node stays ahead of /usr/local/bin", () => { + const current = "/Users/me/.nvm/versions/node/v22.0.0/bin"; + const next = appendMissingPathDirs(current, ["/usr/local/bin"]); + expect(next.startsWith("/Users/me/.nvm/versions/node/v22.0.0/bin")).toBe( + true, + ); + expect(next.endsWith("/usr/local/bin")).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/path-fallback.ts b/apps/desktop/src/main/path-fallback.ts new file mode 100644 index 00000000000..1eab913c578 --- /dev/null +++ b/apps/desktop/src/main/path-fallback.ts @@ -0,0 +1,15 @@ +/** + * Append missing fallback directories onto a PATH string without prepending. + * Prepending (e.g. /usr/local/bin before nvm) shadows a recovered login-shell + * Node with a stale system binary and breaks shebang CLIs during daemon probes. + */ +export function appendMissingPathDirs( + currentPath: string, + fallbackDirs: readonly string[], + separator = ":", +): string { + const current = currentPath.split(separator).filter(Boolean); + const existing = new Set(current); + const missing = fallbackDirs.filter((p) => !existing.has(p)); + return [...current, ...missing].join(separator); +} diff --git a/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx b/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx new file mode 100644 index 00000000000..b8e507ce5e4 --- /dev/null +++ b/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx @@ -0,0 +1,234 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + auth: { + isLoading: false, + retryAuthentication: vi.fn(), + status: "authenticated", + user: { id: "user-1" } as { id: string } | null, + }, + refetchWorkspaceList: vi.fn(), + workspaceListUnavailable: false, +})); + +const tabStore = vi.hoisted(() => ({ + activeWorkspaceSlug: "acme" as string | null, + byWorkspace: {}, + closeActiveTab: vi.fn(), + reset: vi.fn(), + switchWorkspace: vi.fn(), + validateWorkspaceSlugs: vi.fn(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ setQueryData: vi.fn() }), +})); + +vi.mock("@multica/core/platform", () => ({ + CoreProvider: ({ children }: { children: ReactNode }) => <>{children}, + setCurrentWorkspace: vi.fn(), +})); + +vi.mock("@multica/core/i18n", () => ({ + pickLocale: () => "en", +})); + +vi.mock("@multica/core/auth", () => ({ + useAuthStore: (selector: (auth: typeof state.auth) => unknown) => + selector(state.auth), +})); + +vi.mock("@multica/core/onboarding", () => ({ + useWelcomeStore: { getState: () => ({ reset: vi.fn() }) }, +})); + +vi.mock("@multica/core/workspace/queries", () => ({ + workspaceKeys: { list: () => ["workspace-list"] }, +})); + +vi.mock("@multica/core/workspace", () => ({ + useWorkspaceList: () => ({ + isFetching: false, + ready: true, + refetch: state.refetchWorkspaceList, + unavailable: state.workspaceListUnavailable, + workspaces: [{ id: "ws-1", slug: "acme" }], + }), +})); + +vi.mock("@multica/core/api", () => ({ + api: { + listMyInvitations: vi.fn(), + listWorkspaces: vi.fn(), + }, +})); + +vi.mock("@multica/core/paths", () => ({ + useHasOnboarded: () => true, +})); + +vi.mock("@multica/core/analytics", () => ({ captureEvent: vi.fn() })); +vi.mock("@multica/ui/components/common/theme-provider", () => ({ + ThemeProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("@multica/ui/components/common/multica-icon", () => ({ + MulticaIcon: () =>
, +})); +vi.mock("@multica/ui/components/ui/sonner", () => ({ Toaster: () => null })); +vi.mock("@multica/views/locales", () => ({ RESOURCES: { en: {} } })); + +vi.mock("./pages/login", () => ({ + DesktopLoginPage: () =>
, +})); +vi.mock("./pages/auth-recovery", () => ({ + DesktopAuthRecoveryPage: ({ + isRetrying = false, + onRetry, + }: { + isRetrying?: boolean; + onRetry?: () => void; + }) => ( +
+ {onRetry && ( + + )} +
+ ), +})); +vi.mock("./components/desktop-layout", () => ({ + DesktopShell: () =>
, +})); +vi.mock("./components/update-notification", () => ({ + UpdateNotification: () => null, +})); +vi.mock("./components/issue-window", () => ({ + IssueWindow: () =>
, +})); + +vi.mock("./stores/tab-store", () => ({ + useTabStore: Object.assign( + (selector: (value: typeof tabStore) => unknown) => selector(tabStore), + { getState: () => tabStore }, + ), +})); +vi.mock("./stores/window-overlay-store", () => ({ + useWindowOverlayStore: { + getState: () => ({ close: vi.fn(), open: vi.fn(), overlay: null }), + }, +})); + +vi.mock("./hooks/use-open-settings-shortcut", () => ({ + useOpenSettingsShortcut: () => {}, +})); +vi.mock("./hooks/use-tab-selection-shortcut", () => ({ + useTabSelectionShortcut: () => {}, +})); +vi.mock("./platform/daemon-ipc-bridge", () => ({ + useDaemonIPCBridge: () => {}, +})); +vi.mock("./platform/daemon-login-sync", () => ({ + syncDaemonOnLogin: vi.fn(), +})); +vi.mock("./platform/i18n-adapter", () => ({ + createDesktopLocaleAdapter: () => ({ + getSystemPreferences: () => ["en"], + getUserChoice: () => null, + persist: vi.fn(), + }), +})); +vi.mock("./platform/client-usage-reporter", () => ({ + DesktopClientUsageReporter: () => null, +})); +vi.mock("./platform/diagnostic-route-reporter", () => ({ + DiagnosticRouteReporter: () => null, +})); +vi.mock("./freeze-flush", () => ({ flushFreezeBreadcrumb: vi.fn() })); +vi.mock("./platform/auth-session-bridge", () => ({ + DesktopAuthSessionBridge: () => null, +})); +vi.mock("./platform/session-teardown", () => ({ + tearDownOnLogout: vi.fn(), + tearDownOnSessionExpiry: vi.fn(), +})); + +const { default: App } = await import("./App"); + +beforeEach(() => { + state.auth.isLoading = false; + state.auth.status = "authenticated"; + state.auth.user = { id: "user-1" }; + state.refetchWorkspaceList.mockReset(); + state.workspaceListUnavailable = false; + tabStore.activeWorkspaceSlug = "acme"; + + Object.assign(window, { + daemonAPI: { + clearToken: vi.fn(), + restart: vi.fn(), + setTargetApiUrl: vi.fn(), + stop: vi.fn(), + }, + desktopAPI: { + ackFreeze: vi.fn(), + appInfo: { os: "macos", version: "0.5.1" }, + closeWindow: vi.fn(), + getLastFreeze: vi.fn(), + onAuthToken: () => () => {}, + onCloseActiveTab: () => () => {}, + onInviteOpen: () => () => {}, + onSystemLocaleChanged: () => () => {}, + reportAuthSession: vi.fn(), + runtimeConfig: { + config: { apiUrl: "http://localhost", wsUrl: "ws://localhost" }, + ok: true, + }, + systemLocale: "en", + windowContext: { kind: "main" }, + }, + }); +}); + +describe("App main-window auth recovery", () => { + it("keeps auth recovery ahead of the desktop shell while the session recovers", () => { + state.auth.status = "recovering"; + + render(); + + expect(screen.getByTestId("auth-recovery")).toBeInTheDocument(); + expect(screen.queryByTestId("desktop-shell")).toBeNull(); + }); + + it("uses recovery with retry when the workspace list is unavailable", () => { + state.workspaceListUnavailable = true; + + render(); + + expect(screen.getByTestId("auth-recovery")).toBeInTheDocument(); + expect(screen.queryByTestId("desktop-shell")).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Retry workspace list" }), + ); + expect(state.refetchWorkspaceList).toHaveBeenCalledOnce(); + }); + + it("keeps auth recovery ahead of a dedicated issue window", () => { + state.auth.status = "recovering"; + window.desktopAPI.windowContext = { + issueId: "MUL-1", + kind: "issue", + path: "/acme/issues/MUL-1", + title: "MUL-1", + workspaceSlug: "acme", + }; + + render(); + + expect(screen.getByTestId("auth-recovery")).toBeInTheDocument(); + expect(screen.queryByTestId("issue-window")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx index 844baf10f97..a16c7fac439 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@multica/core/i18n/react"; @@ -84,9 +84,21 @@ vi.mock("./window-overlay", () => ({ WindowOverlay: () => null })); // a `PageHeader` reads before deciding to render its own fallback trigger. vi.mock("./tab-content", () => ({ TabContent: () => { - const { hasExternalTrigger } = useSidebar(); + const { hasExternalTrigger, isCompact, setOpen, state } = useSidebar(); return ( -
+
+ + +
); }, })); @@ -115,6 +127,14 @@ function renderShell() { ); } +beforeEach(() => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 1280, + }); + localStorage.clear(); +}); + describe("DesktopShell sidebar trigger", () => { // The window toolbar parks a trigger beside the traffic lights that never // scrolls away, so nothing inside the canvas may add a second one. Desktop @@ -123,7 +143,7 @@ describe("DesktopShell sidebar trigger", () => { // this one — and a third when a list/detail surface brought its own header // along (MUL-6218). it("keeps exactly one trigger and tells page headers not to add another", () => { - const { container, getByTestId } = renderShell(); + const { container, getByTestId, queryByRole } = renderShell(); expect(container.querySelectorAll("[data-slot='sidebar-trigger']")).toHaveLength(1); expect(getByTestId("page-content")).toHaveAttribute( @@ -142,5 +162,37 @@ describe("DesktopShell sidebar trigger", () => { container.querySelector('[data-slot="sidebar-trigger"]')!, ); expect(mainTopBar).toHaveStyle({ paddingLeft: "256px" }); + expect(queryByRole("status", { name: /loading workspace/i })).toBeNull(); + }); + + it("keeps full clearance for compact layouts in either sidebar state", () => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 800, + }); + + const { container, getByRole, getByTestId } = renderShell(); + const mainTopBar = container.querySelector('[data-slot="main-top-bar"]'); + + expect(getByTestId("page-content")).toHaveAttribute("data-compact", "true"); + expect(getByTestId("page-content")).toHaveAttribute( + "data-sidebar-state", + "expanded", + ); + expect(mainTopBar).toHaveStyle({ paddingLeft: "256px" }); + + fireEvent.click(getByRole("button", { name: "Collapse sidebar" })); + expect(getByTestId("page-content")).toHaveAttribute( + "data-sidebar-state", + "collapsed", + ); + expect(mainTopBar).toHaveStyle({ paddingLeft: "256px" }); + + fireEvent.click(getByRole("button", { name: "Expand sidebar" })); + expect(getByTestId("page-content")).toHaveAttribute( + "data-sidebar-state", + "expanded", + ); + expect(mainTopBar).toHaveStyle({ paddingLeft: "256px" }); }); }); diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx index 37501db1379..70ec88f5476 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useSyncExternalStore } from "react"; import { motion } from "motion/react"; import { useQuery } from "@tanstack/react-query"; import { cn } from "@multica/ui/lib/utils"; +import { MulticaIcon } from "@multica/ui/components/common/multica-icon"; import { useNavigationInputBindings, useTabHistory, @@ -26,6 +27,7 @@ import { } from "@multica/views/navigation"; import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform"; import { useDesktopUnreadBadge } from "@multica/views/platform"; +import { useT } from "@multica/views/i18n"; import { DesktopNavigationProvider, routeContentLinkPath, @@ -67,9 +69,9 @@ function useNativeNavigationGestures() { // do not land beneath the traffic lights / navigation controls. The matching // 200ms transition cancels the sidebar gap's movement during toggle; live // resize previews disable it through data-sidebar-resize-consumer. -function MainTopBar() { +function MainTopBar({ sidebarMounted }: { sidebarMounted: boolean }) { const { state, isCompact } = useSidebar(); - const sidebarHidden = state === "collapsed" || isCompact; + const sidebarHidden = !sidebarMounted || state === "collapsed" || isCompact; const toolbarClearance: React.CSSProperties["paddingLeft"] = sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : `max(0px, calc(${WINDOW_TOOLBAR_CLEARANCE}px - var(--sidebar-live-width, var(--sidebar-width))))`; @@ -107,9 +109,17 @@ function MainTopBar() { // The canvas hugs the expanded sidebar with a hairline gap. When the sidebar // leaves the main flow, the left margin must grow to mirror the fixed mr-2 so // the floating canvas sits symmetrically inside the window frame. -function MainCanvas({ children }: { children: React.ReactNode }) { +function MainCanvas({ + children, + showWorkspaceLoading, +}: { + children: React.ReactNode; + showWorkspaceLoading: boolean; +}) { const { state, isCompact } = useSidebar(); + const { t } = useT("layout"); const sidebarHidden = state === "collapsed" || isCompact; + const loadingLabel = t(($) => $.workspace_loader.loading_workspace); return ( {children} + {showWorkspaceLoading && ( +
+
+ +

{loadingLabel}

+
+
+ )}
); } @@ -251,8 +274,8 @@ export function DesktopShell() { {slug && } searchSlot={} />} {/* Right side: header + content container */}
- - + + {/* Same indicator, same anchor as web: DashboardLayout puts it at the top of SidebarInset, and MainCanvas is desktop's equivalent relative/overflow-hidden content box. Desktop diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx index 2b28b7af8b0..599795d6b65 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx @@ -139,19 +139,43 @@ function renderShell() { } beforeEach(() => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 1280, + }); + localStorage.clear(); state.currentSlug = "acme"; state.wsList = [{ id: "ws-1", slug: "acme" }]; }); describe("DesktopShell workspace gating", () => { it("mounts workspace-scoped chrome while the slug resolves", () => { - const { queryByTestId } = renderShell(); + const { queryByRole, queryByTestId } = renderShell(); expect(queryByTestId("app-sidebar")).not.toBeNull(); expect(queryByTestId("search-command")).not.toBeNull(); expect(queryByTestId("global-shortcuts")).not.toBeNull(); expect(queryByTestId("modal-registry")).not.toBeNull(); expect(queryByTestId("floating-chat")).not.toBeNull(); + expect(queryByRole("status", { name: /loading workspace/i })).toBeNull(); + }); + + it("keeps traffic-light clearance and a loading shell while the slug is unresolved", () => { + state.currentSlug = null; + + const { container, getByRole, getByTestId, queryByTestId } = renderShell(); + + expect(queryByTestId("app-sidebar")).toBeNull(); + expect( + container.querySelectorAll("[data-slot='sidebar-trigger']"), + ).toHaveLength(0); + expect( + container.querySelector('[data-slot="main-top-bar"]'), + ).toHaveStyle({ paddingLeft: "256px" }); + expect( + getByRole("status", { name: /loading workspace/i }), + ).toBeVisible(); + expect(getByTestId("tab-content")).toBeInTheDocument(); }); it("drops workspace-scoped chrome when the singleton still points at a deleted workspace", () => { @@ -166,6 +190,7 @@ describe("DesktopShell workspace gating", () => { expect(queryByTestId("global-shortcuts")).toBeNull(); expect(queryByTestId("modal-registry")).toBeNull(); expect(queryByTestId("floating-chat")).toBeNull(); + expect(queryByTestId("tab-content")).not.toBeNull(); }); it("keeps TabContent mounted with no workspace so the tab router can still resolve one", () => { diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx index ebee47a9c10..9c7448d8f1e 100644 --- a/apps/docs/app/[lang]/layout.tsx +++ b/apps/docs/app/[lang]/layout.tsx @@ -50,7 +50,7 @@ export const metadata: Metadata = { default: "Multica Docs", }, description: - "Documentation for Multica — the open-source managed agents platform.", + "Documentation for Multica — the source-available managed agents platform.", }; export function generateStaticParams() { diff --git a/apps/docs/content/docs/agents-create.fr.mdx b/apps/docs/content/docs/agents-create.fr.mdx new file mode 100644 index 00000000000..98e9df91dc8 --- /dev/null +++ b/apps/docs/content/docs/agents-create.fr.mdx @@ -0,0 +1,169 @@ +--- +title: Créer et configurer un agent +description: Choisissez un point de départ, puis définissez les responsabilités, les capacités, la configuration d'exécution et l'Accès de l'agent. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Pour créer un agent, il faut d'abord un [runtime](/daemon-runtimes) fonctionnel. Le runtime détermine l'ordinateur et l'outil de codage IA utilisés par l'agent ; l'agent porte l'identité durable, les instructions et les capacités. + +Sur la page **Agents** de l'espace de travail, cliquez sur **Nouvel agent**. + +## Points de départ + +La page de création propose deux options : + +| Option | Quand l'utiliser | +|---|---| +| **Partir de zéro** | Vous connaissez déjà les responsabilités de l'agent et souhaitez remplir vous-même chaque champ. | +| **Construire avec l'IA** | Décrivez d'abord l'objectif, puis laissez l'Agent Builder poser les questions clés et générer un brouillon. | + +**Construire avec l'IA** nécessite un runtime en ligne pour la conversation avec le Builder. Dans les deux cas, vous pouvez relire et modifier la configuration finale avant la création. + +![Points de départ de la création d'un agent : Partir de zéro et Construire avec l'IA](/images/docs/quickstart-new-agent.webp) + +## Champs obligatoires + +La création d'un agent ne requiert que deux éléments : + +- **Nom** — doit être unique dans l'espace de travail. +- **Runtime** — l'environnement qui effectue réellement les exécutions. + +Tous les autres champs peuvent garder leur valeur par défaut et être ajustés après la création. Par défaut, seul le créateur peut exécuter un nouvel agent. + +## Description et instructions + +La **description** est une courte présentation destinée à l'équipe. Elle n'apparaît que dans la liste des agents et sur la page de détail, et n'entre jamais dans le prompt de l'outil de codage IA. + +Les **instructions** sont fournies à l'agent à chaque exécution et couvrent généralement : + +- ce dont il est responsable, et ce dont il ne l'est pas ; +- ce qu'il doit vérifier en premier lorsqu'un travail arrive ; +- ce qu'il est autorisé à modifier ; +- comment livrer les résultats ; +- quand consulter un membre avant de poursuivre. + +Par exemple : + +```text +Vous relisez les pull requests frontend. + +Lisez d'abord le diff et les tests associés, et vérifiez uniquement : +- la correction du code React et TypeScript +- l'accessibilité +- la cohérence avec les patterns de composants existants + +Ne modifiez pas le code directement. Publiez vos constats dans un commentaire de la tâche, +classés par gravité ; si rien n'est bloquant, indiquez clairement que la modification peut être fusionnée. +``` + +## Ajouter des skills + +Lors de la création, vous pouvez choisir un ou plusieurs [skills](/skills) de l'espace de travail. + +Les skills conviennent aux méthodes et aux supports réutilisés par plusieurs agents ; les exigences durables propres à cet agent vont dans les instructions. + +## Amorces de conversation + +Ajoutez jusqu'à trois **amorces de conversation** pour montrer ce que cet agent sait bien faire avant que l'on envoie un premier message dans une discussion. Elles apparaissent au-dessus du champ de saisie lorsque quelqu'un ouvre une nouvelle discussion avec l'agent. Chaque amorce comporte un libellé court et un prompt complet. En choisir une remplit le champ de saisie pour que l'utilisateur puisse la relire ou la modifier ; elle ne lance jamais d'exécution automatiquement. + +L'éditeur affiche un aperçu exact de ce que montrera une nouvelle discussion, et toute personne pouvant modifier l'agent voit, dans cet état vide, un lien **Personnaliser les amorces** qui ramène directement ici. + +Si vous laissez la liste vide, la Discussion affiche des amorces génériques par défaut, localisées. Des exemples propres à l'agent sont généralement plus utiles, car ils peuvent refléter le rôle et les limites définis dans les instructions. + +Depuis le CLI, définissez-les avec `--conversation-starters` sur `agent create` et `agent update` (un tableau JSON d'objets `{ "label", "prompt" }` ; passez `'[]'` lors d'une mise à jour pour les effacer). `agent copy` copie toujours la valeur de l'agent source et ne propose pas d'option pour la remplacer. + +## Runtime, modèle et niveau de réflexion + +Chaque runtime correspond déjà à un outil de codage IA. Après avoir choisi un runtime, vous pouvez choisir un modèle et un niveau de réflexion pris en charge par l'outil ; certains outils (comme Codex) proposent aussi un niveau de service (**Vitesse** dans l'interface) : + +- Laissé vide, c'est la valeur par défaut du runtime ou du CLI local qui s'applique. +- Si un modèle est défini, l'agent utilise cette surcharge pour les exécutions qu'il récupère par la suite. +- Certains runtimes gèrent eux-mêmes les modèles ; aucun sélecteur de modèle n'est alors affiché. + +Les outils diffèrent par les modèles pris en charge, la reprise de session, les skills et les capacités MCP — voir [Comparatif des outils de codage IA](/providers). + +## Accès + +L'Accès détermine quels membres peuvent exécuter cet agent (l'assigner, l'@mentionner ou discuter avec lui) : + +| Accès | Signification | +|---|---| +| **Moi uniquement** | Vous seul pouvez l'exécuter. Valeur par défaut. | +| **Tout l'espace de travail** | Tous les membres de l'espace de travail peuvent l'exécuter. | +| **Personnes précises** | Seuls vous et les membres sélectionnés pouvez l'exécuter. | + +![Paramètres d'Accès : Moi uniquement, Tout l'espace de travail, Personnes précises](/images/docs/agent-access-settings.webp) + +Seul le propriétaire de l'agent peut modifier l'Accès — les administrateurs de l'espace de travail ne le peuvent pas. Les `owner` et `admin` de l'espace de travail peuvent gérer le reste de la configuration, mais ne peuvent pas se servir de leur rôle d'administrateur pour exécuter des agents auxquels ils n'ont pas accès. + +## Configuration après la création + +Sur la page de détail de l'agent, vous pouvez continuer à ajuster : + +| Paramètre | Rôle | +|---|---| +| **Parallélisme** | Nombre d'exécutions que l'agent peut mener en même temps. La valeur par défaut est 6 ; les exécutions au-delà de ce plafond restent en file d'attente. | +| **Variables d'environnement** | Injectent des variables au démarrage de l'outil de codage IA. | +| **Arguments personnalisés** | Ajoutés un par un aux arguments CLI de l'outil de codage IA. | +| **MCP** | Fournit une configuration de serveurs MCP aux outils de codage IA qui la prennent en charge. | +| **Intégrations** | Connecte les services externes que cet agent peut utiliser. | + +Le daemon qui héberge le runtime a aussi un plafond global de parallélisme (20 par défaut) ; le parallélisme effectif est le plus petit des deux. + +Modifier la configuration ne change pas les exécutions déjà en cours. Les exécutions suivantes utilisent la configuration de l'agent enregistrée au moment où le runtime les récupère. + +## Variables d'environnement et identifiants + +Les variables d'environnement conviennent aux identifiants à privilèges limités dont l'agent a besoin pendant l'exécution, comme une clé d'API en lecture seule ou un jeton à portée unique. + + +**Les valeurs de `custom_env` sont stockées en clair dans la base de données du serveur Multica** — il ne s'agit pas de données qui « restent sur cette machine ». Les endpoints de liste et de détail des agents ne renvoient plus aucune valeur de variable, seulement un nombre opaque ; seuls les `owner` et `admin` de l'espace de travail peuvent déverrouiller et modifier les valeurs, et chaque lecture ou modification laisse une trace d'audit. Un agent en cours d'exécution ne peut pas appeler les endpoints d'administration pour lire les variables d'un autre agent. + + +N'utilisez pas de mots de passe d'administration de bases de données de production ni d'autres identifiants de grande valeur à longue durée de vie. + +Les variables d'exécution critiques comme `PATH`, `HOME` et `MULTICA_*` ne peuvent pas être remplacées ici. + +## Arguments personnalisés et MCP + +Les arguments personnalisés sont transmis à l'outil de codage IA sous forme de tableau, élément par élément, sans expansion par le shell. La validité d'un argument dépend de l'outil ; inutile de répéter ici une configuration de modèle qui dispose déjà d'un champ dédié. + + +Ne placez pas d'identifiants ni d'autres secrets dans les arguments personnalisés. Ils restent dans l'argv du processus enfant et peuvent être visibles par d'autres processus locaux via `ps` ou `/proc` ; utilisez plutôt les variables d'environnement (`custom_env`). Les journaux du daemon masquent les valeurs des arguments, mais cela ne protège pas la liste des processus du système d'exploitation. + + +La configuration MCP peut contenir des jetons ; ses règles de stockage et d'affichage sont les mêmes que pour les variables d'environnement. Seuls les runtimes qui prennent en charge la configuration MCP gérée affichent cette section ; les outils sans prise en charge de MCP ne l'acquièrent pas simplement parce qu'une configuration est enregistrée. + +## Dupliquer un agent + +La duplication reprend l'essentiel de la configuration de travail, sauf le nom : instructions, amorces de conversation, skills, arguments personnalisés, avatar, limite de parallélisme et paramètres d'Accès. Le modèle, le niveau de réflexion et la vitesse (niveau de service) sont également repris ; si le runtime d'origine est indisponible et que la copie doit passer sur un autre runtime, ces trois valeurs sont effacées et doivent être choisies à nouveau. + +Les valeurs des variables d'environnement et la configuration MCP ne sont jamais copiées et doivent être redéfinies sur le nouvel agent. La duplication conserve le rattachement au runtime et les paramètres d'Accès de l'agent d'origine. + +## Créer avec le CLI + +```bash +multica agent create \ + --name "Frontend Reviewer" \ + --runtime-id \ + --description "Relit les pull requests frontend" \ + --instructions "Lisez d'abord le diff et les tests ; publiez vos conclusions de relecture uniquement dans les commentaires de la tâche." \ + --conversation-starters '[{"label":"Relire une PR","prompt":"Relisez la pull request ouverte la plus pertinente."}]' +``` + +Le texte en clair passé en argument de ligne de commande finit dans l'historique du shell ; ce n'est pas le cas de stdin ni des fichiers à accès restreint. + +Lorsqu'un agent configuré de façon similaire existe déjà, copiez-le directement : + +```bash +multica agent copy +``` + +Par défaut, la copie reste sur le même runtime ; ajoutez `--runtime-id` pour la déplacer vers un autre runtime, ce qui requiert aussi `--model`. Les variables d'environnement, la configuration MCP et `runtime_config` ne sont jamais copiés ; le rattachement au runtime lui-même est conservé. Toutes les options dans [Utiliser le CLI](/cli). + +## Étapes suivantes + +- [Assigner des tâches aux agents](/assigning-issues) — validez la configuration avec du vrai travail. +- [Skills](/skills) — créez, importez et réutilisez les méthodes de l'équipe. +- [Daemon et runtimes](/daemon-runtimes) — diagnostiquez l'état en ligne et l'endroit où s'effectue l'exécution. diff --git a/apps/docs/content/docs/agents.fr.mdx b/apps/docs/content/docs/agents.fr.mdx new file mode 100644 index 00000000000..621446c1f8f --- /dev/null +++ b/apps/docs/content/docs/agents.fr.mdx @@ -0,0 +1,91 @@ +--- +title: Agents +description: Un agent est un ensemble réutilisable d'identité, de capacités et de configuration d'exécution au sein d'un espace de travail. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Un agent est un collaborateur d'un [espace de travail Multica](/workspaces). Vous pouvez lui assigner des tâches, le @mentionner dans des commentaires ou discuter directement avec lui. Il pilote un outil de codage IA via le runtime auquel il est associé, et renvoie son avancement et ses résultats dans l'espace de travail. + +Un agent n'est pas un processus qui tourne en continu. C'est une identité et une configuration réutilisables ; il ne produit des [exécutions](/tasks) concrètes que lorsque du travail arrive. + +## Configuration d'un agent + +| Paramètre | Rôle | +|---|---| +| **Nom, avatar et description** | Indiquent à l'équipe qui est l'agent et dans quoi il excelle. La description sert uniquement à l'affichage et n'entre jamais dans le prompt d'exécution. | +| **Instructions** | Définissent ses responsabilités, sa façon de travailler, ses limites et ses exigences de livraison. Utilisées à chaque exécution. | +| **Amorces de conversation** | Proposent jusqu'à trois exemples propres à l'agent, affichés lorsqu'une personne ouvre une nouvelle discussion avec lui. En sélectionner une remplit le champ de saisie sans envoyer le message. | +| **Skills** | Fournissent des méthodes réutilisables, des documents de référence et des fichiers d'appui. | +| **Runtime, modèle et niveau de réflexion** | Déterminent le runtime, l'outil de codage IA et le modèle utilisés ; certains outils (comme Codex) proposent aussi un niveau de service (**Vitesse**). | +| **Accès** | Détermine quels membres peuvent l'exécuter. | +| **Paramètres d'exécution** | Comprennent la limite de parallélisme, les variables d'environnement, les arguments CLI, MCP et les intégrations externes. | + +Changer de modèle ou modifier les instructions ne crée pas de nouvel agent, et l'historique des tâches, des commentaires et des exécutions n'est pas perdu. + +## Agents, runtimes et exécutions + +| Concept | Responsabilité | +|---|---| +| **Agent** | Décide qui fait le travail et comment | +| **Runtime** | Décide quel ordinateur et quel outil de codage IA l'exécutent | +| **Exécution** | Enregistre le déroulement et le résultat d'une exécution | + +Un runtime peut héberger plusieurs agents, et un agent peut mener à bien de nombreuses exécutions au fil du temps. Lorsque le runtime est hors ligne, l'identité et l'historique de l'agent sont conservés, mais les nouvelles exécutions attendent le retour du runtime. + +## Comment un agent participe + +- **Prendre en charge une tâche** — définissez l'agent comme assigné ; il démarre dès que l'exécution est mise en file d'attente. +- **Traiter un commentaire** — @mentionnez l'agent dans un commentaire sans changer l'assigné de la tâche. +- **Poursuivre une discussion** — répondez au commentaire de l'agent ; les nouvelles informations alimentent les exécutions suivantes. +- **Discuter directement** — démarrez une discussion qui n'est rattachée à aucune tâche. +- **Rejoindre des projets et des squads** — un agent peut être responsable d'un projet, chef d'un squad ou membre d'un squad. +- **S'exécuter via des automatisations** — déclenchez un travail prédéfini selon une planification ou à partir d'événements externes, ou lancez-le manuellement. + +Les agents peuvent créer des tâches, publier des commentaires et changer le statut du travail, mais ils n'ont pas de boîte de réception et ne reçoivent pas `@all`. Une @mention d'un agent est un déclencheur d'exécution, pas une notification. + +## Permissions et accès + +Chaque agent a un propriétaire et un paramètre **Accès** : + +| Accès | Qui peut l'exécuter | +|---|---| +| **Moi uniquement** | Uniquement le propriétaire de l'agent. | +| **Tout l'espace de travail** | Tous les membres de l'espace de travail. | +| **Personnes précises** | Le propriétaire de l'agent et les membres sélectionnés. | + +Les nouveaux agents sont par défaut en **Moi uniquement**. + + +Les `owner` et les `admin` de l'espace de travail peuvent voir et gérer tous les agents, mais ne peuvent pas contourner l'Accès pour exécuter l'agent « Moi uniquement » de quelqu'un d'autre. Les membres ordinaires ne voient que les agents dont ils sont propriétaires ou qu'ils sont autorisés à exécuter. + + +La possibilité de **voir** un agent dépend à la fois de la propriété, des rôles d'administration de l'espace de travail et de l'Accès ; la **modification** est ouverte au propriétaire de l'agent ainsi qu'aux propriétaires et administrateurs de l'espace de travail, à l'exception de l'Accès lui-même, que seul le propriétaire de l'agent peut modifier. + +Les éléments de configuration susceptibles de contenir des identifiants, comme les variables d'environnement et MCP, obéissent à des règles de lecture plus strictes — voir [Créer et configurer un agent](/agents-create#variables-denvironnement-et-identifiants). + +## En ligne, hors ligne et charge + +La liste des agents affiche deux types de statut côte à côte : + +- La **Disponibilité** provient du runtime — en ligne, hors ligne ou instable. +- La **Charge** provient des exécutions — au travail, en file d'attente ou au repos. + +« Hors ligne » ne signifie pas que l'agent a été supprimé. Les exécutions déjà en file d'attente attendent le rétablissement du runtime ; seul « archivé » signifie qu'il ne peut plus recevoir de nouveau travail. + +## Archiver et restaurer + +Les agents que vous n'utilisez plus peuvent être archivés. Un agent archivé n'apparaît plus dans les sélecteurs et ne peut plus être assigné ni @mentionné ; son historique est conservé et il peut être restauré plus tard. + + +L'archivage annule toutes les exécutions non terminées de l'agent, y compris celles en file d'attente et en cours. + + +Le propriétaire de l'agent ainsi que les `owner` et les `admin` de l'espace de travail peuvent archiver ou restaurer un agent. + +## Étapes suivantes + +- [Créer et configurer un agent](/agents-create) — partez de zéro, ou construisez-le avec l'IA. +- [Skills](/skills) — ajoutez des méthodes et des documents réutilisables à un agent. +- [Squads](/squads) — laissez un chef choisir le membre le plus adapté au travail. +- [Daemon et runtimes](/daemon-runtimes) — là où les agents s'exécutent réellement. diff --git a/apps/docs/content/docs/assigning-issues.fr.mdx b/apps/docs/content/docs/assigning-issues.fr.mdx new file mode 100644 index 00000000000..eba4ac724a3 --- /dev/null +++ b/apps/docs/content/docs/assigning-issues.fr.mdx @@ -0,0 +1,88 @@ +--- +title: Assigner des tâches aux agents +description: Confiez une tâche à un agent et décidez s'il commence à travailler immédiatement. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Lorsqu'un travail doit être pris en charge par un agent dans la durée, assignez-lui la tâche. L'agent lit la description et la discussion de la tâche, effectue le travail avec sa propre configuration et ses skills, puis consigne l'avancement et les résultats dans la même tâche. + +Si vous avez seulement besoin que l'agent traite une demande précise sans changer de responsable, utilisez plutôt une [@mention dans un commentaire](/mentioning-agents). + +## Assigner et lancer une exécution + +1. Ouvrez une tâche et cliquez sur **Assigné à**. +2. Choisissez un agent ou un squad. +3. Vérifiez l'agent sur le point de démarrer dans la boîte de dialogue de confirmation, puis choisissez **Confirmer l'assignation**. + +![Boîte de dialogue de confirmation de l'assignation : confirmer l'assignation, ou choisir de ne pas démarrer maintenant](/images/docs/assign-confirm-dialog.webp) + +Tout ce que l'agent doit savoir sur ce travail doit figurer dans la tâche elle-même — sa description, sa discussion, ou encore la description du projet et les instructions de l'agent. + +Lorsque vous assignez la tâche à un squad, le squad devient l'assigné de la tâche et le chef du squad démarre en premier. Il décide s'il confie le travail à d'autres membres, selon le fonctionnement du squad. + +## Exécution après l'assignation + +Multica crée une [exécution](/tasks) pour l'agent. Dès qu'un runtime en ligne récupère l'exécution, il lance l'outil de codage IA sur cette machine ; l'avancement, les commentaires et les résultats apparaissent ensuite dans la tâche. + +Si le runtime est temporairement hors ligne, l'exécution attend dans la file ; une exécution est liée à son runtime et ne passe jamais sur une autre machine. + +Pendant l'exécution, l'agent peut : + +- lire la description, les champs et les commentaires de la tâche ; +- utiliser ses skills rattachés, ses serveurs MCP et le contexte du projet ; +- lire des fichiers, exécuter des commandes et apporter des modifications dans son répertoire de travail local ; +- publier des commentaires et mettre à jour le statut de la tâche. + +Ce qu'il peut réellement faire reste limité par la configuration de l'agent, l'environnement du runtime et votre droit de l'exécuter. + +Pendant l'exécution, l'agent gère le statut de la tâche en évaluant ce que son travail change : commencer à traiter la demande propre à la tâche la fait passer immédiatement à `in_progress`, une livraison la fait passer à `in_review`, un travail qui se poursuit au-delà du tour la maintient en `in_progress`, et une consultation ou une discussion laisse le statut inchangé — ces actions apparaissent sous l'identité de l'agent dans la chronologie. Le cycle de vie de l'exécution et le statut de la tâche sont distincts : la fin d'une exécution ne change pas à elle seule le statut de la tâche. + +Lorsque l'assigné est un [squad](/squads), la même règle s'applique : Multica met le chef en file, et répartir le travail entre les membres n'est pas une livraison — un tour de répartition laisse la tâche parente en `in_progress`, et le chef ne la passe en `in_review` qu'une fois l'objectif global atteint. + +## Assigner sans démarrer + +**Ne pas démarrer maintenant** dans la boîte de dialogue de confirmation enregistre l'assigné sans créer d'exécution cette fois-ci. Utilisez-le pour régler d'abord la question du responsable, puis démarrer une fois le contexte ou les dépendances prêts. + +`backlog` ne lance pas non plus d'exécution lors de l'assignation. L'exécution ne suit qu'une fois la tâche sortie de `backlog`, ou après une demande adressée à l'assigné dans un commentaire ultérieur. + +Lorsque la tâche a déjà un agent ou un squad comme responsable, la sortir de `backlog` ouvre la même boîte de dialogue : c'est ce changement de statut qui lance l'exécution, elle propose donc le même bouton **Ne pas démarrer maintenant**, qui déplace la tâche sans réveiller l'agent. La boîte de dialogue apparaît pour toute cible autre que `backlog` lui-même et que les statuts des catégories `done` ou `closed`. + +Seul le statut fixe `backlog` fonctionne ainsi. Un [statut personnalisé](/issues#statuts-personnalisés) de la catégorie `unstarted` ne met **pas** le travail en attente — assigner un agent à une tâche dans ce statut lance immédiatement une exécution. Les tâches déjà en `done` ou `cancelled` lancent quand même une exécution immédiatement lorsqu'elles sont assignées ou réassignées à un agent. + +## Droits d'assignation + +Votre capacité à assigner un agent dépend de son Accès : + +- **Moi uniquement** : seul le propriétaire de l'agent peut l'exécuter ; +- **Personnes précises** : le propriétaire et les membres listés peuvent l'exécuter ; +- **Tout l'espace de travail** : tous les membres de l'espace de travail peuvent l'exécuter. + +Les propriétaires et administrateurs de l'espace de travail peuvent gérer les agents, mais pouvoir voir un agent ne signifie pas pouvoir l'exécuter. Le sélecteur d'assignation désactive les agents que vous ne pouvez pas exécuter. Voir [Agents](/agents#permissions-et-accès) pour les règles complètes. + +## Changer ou retirer l'assigné + +Passer l'assignation à un autre agent lance une nouvelle exécution pour le nouvel assigné ; choisir un membre change seulement l'assigné et ne lance aucun outil de codage IA. Retirer l'assigné ne crée pas non plus d'exécution. + + +Changer l'assigné, retirer l'assignation ou changer le statut de la tâche n'arrête pas une exécution déjà démarrée. Pour l'interrompre, arrêtez l'exécution correspondante dans le journal d'exécution. + + +## Utiliser le CLI + +```bash +multica issue assign MUL-42 --to "Agent name" +multica issue assign MUL-42 --to-id --no-start +multica issue status MUL-42 in_progress --no-start +multica issue assign MUL-42 --unassign +``` + +Dans les scripts, utilisez `--to-id ` pour éviter de cibler un mauvais objet portant le même nom. Retrouvez les UUID avec `multica agent list --output json` ou `multica workspace member list --output json`. + +Utilisez `--no-start` lorsque l'assignation doit enregistrer le responsable sans créer de nouvelle exécution — par exemple lorsque le même agent traite déjà la tâche depuis une autre exécution. La même option est disponible sur `issue update` et `issue status`. Si un flux qui ne fait qu'enregistrer le responsable modifie à la fois l'assignation et le statut, passez-la aux deux commandes : neutraliser le déclenchement de l'assignation ne neutralise pas une exécution déclenchée ensuite par le changement de statut. + +## Étapes suivantes + +- [@mentionner des agents dans les commentaires](/mentioning-agents) — traitez une nouvelle question sans changer l'assigné. +- [Exécutions](/tasks) — règles de mise en file, de nouvelle tentative et d'arrêt. +- [Squads](/squads) — confiez à un chef la coordination de plusieurs agents. diff --git a/apps/docs/content/docs/auth-setup.fr.mdx b/apps/docs/content/docs/auth-setup.fr.mdx new file mode 100644 index 00000000000..b6921d64b9e --- /dev/null +++ b/apps/docs/content/docs/auth-setup.fr.mdx @@ -0,0 +1,163 @@ +--- +title: Connexion et inscription +description: Configurez les codes de vérification par e-mail, la connexion avec Google et les personnes autorisées à s'inscrire. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Par défaut, Multica connecte les utilisateurs au moyen de codes de vérification envoyés par e-mail ; Google OAuth peut être ajouté en complément. Les utilisateurs existants peuvent toujours se reconnecter — les restrictions d'inscription décident uniquement si de nouveaux comptes peuvent être créés. + +## Codes de vérification par e-mail + +Une fois que l'utilisateur a saisi une adresse e-mail, Multica envoie un code de vérification à 6 chiffres. Le code est valable 10 minutes ; une fois vérifié, le navigateur reçoit un cookie de connexion. + +Les e-mails peuvent être envoyés via Resend ou SMTP. Lorsque les deux sont configurés, `SMTP_HOST` est prioritaire. + +### Utiliser Resend + +1. Vérifiez un domaine d'envoi et créez une clé d'API sur [Resend](https://resend.com/). +2. Définissez : + + ```dotenv + RESEND_API_KEY=re_xxxxxxxxxxxxxxxx + RESEND_FROM_EMAIL=noreply@example.com + ``` + +3. Redémarrez le service API. + +`RESEND_FROM_EMAIL` doit appartenir à un domaine déjà vérifié dans Resend. + +### Utiliser SMTP + +Définissez au minimum l'hôte et l'adresse de l'expéditeur : + +```dotenv +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=multica +SMTP_PASSWORD= +SMTP_FROM_EMAIL=noreply@example.com +``` + +Modes de connexion courants : + +| Scénario | Configuration | +|---|---| +| Relais anonyme interne | `SMTP_PORT=25`, laissez le nom d'utilisateur et le mot de passe vides | +| STARTTLS | `SMTP_PORT=587` ; passe à TLS par défaut lorsque le serveur le prend en charge | +| TLS implicite | `SMTP_PORT=465`, ou définissez explicitement `SMTP_TLS=implicit` | + +Si `SMTP_FROM_EMAIL` n'est pas défini, il se rabat sur `RESEND_FROM_EMAIL`. Avec une autorité de certification privée ou des certificats auto-signés, vous devez ajouter l'autorité de certification au magasin de confiance du conteneur ; `SMTP_TLS_INSECURE=true` désactive la vérification des certificats et ne doit être utilisé que temporairement, sur un réseau interne de confiance. + +Certains relais stricts exigent aussi un nom EHLO valide : + +```dotenv +SMTP_EHLO_NAME=mail.example.com +``` + +### Comportement sans service d'e-mail + +Le serveur démarre quand même, mais les codes de vérification et les liens d'invitation sont uniquement écrits dans le journal ; aucun e-mail n'est envoyé. Cela convient au développement local, pas à la production. + +Le journal de démarrage indique si le mode actuel est `Resend API`, `SMTP relay` ou `DEV mode`. + +## Code de vérification local fixe + +Les tests automatisés locaux peuvent définir un code de vérification fixe : + +```dotenv +APP_ENV=development +MULTICA_DEV_VERIFICATION_CODE=888888 +``` + +Le code doit comporter 6 chiffres. Le code fixe est ignoré lorsque `APP_ENV=production`. + + +N'activez pas de code fixe sur une instance accessible publiquement. La combinaison de production est `APP_ENV=production` avec un `MULTICA_DEV_VERIFICATION_CODE` vide. + + +## Connexion avec Google + +1. Créez un client OAuth 2.0 dans la [Google Cloud Console](https://console.cloud.google.com/). +2. Ajoutez l'URL de rappel du frontend Multica dans **Authorized redirect URIs** (« URI de redirection autorisés » dans la console en français) : + + ```text + https://multica.example.com/auth/callback + ``` + +3. Définissez : + + ```dotenv + GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com + GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx + GOOGLE_REDIRECT_URI=https://multica.example.com/auth/callback + ``` + +4. Redémarrez le service API. + +Les URL indiquées dans la Google Console et dans `GOOGLE_REDIRECT_URI` doivent correspondre exactement, y compris le protocole, le port et la barre oblique finale. Une fois la configuration terminée, la page de connexion affiche un bouton **Continuer avec Google** ; l'image du frontend n'a pas besoin d'être reconstruite. + +## Restrictions d'inscription + +Trois variables déterminent ensemble si un nouveau compte peut être créé : + +| Variable | Effet | +|---|---| +| `ALLOWED_EMAILS` | Adresses e-mail complètes autorisées à s'inscrire, séparées par des virgules | +| `ALLOWED_EMAIL_DOMAINS` | Domaines e-mail autorisés à s'inscrire, séparés par des virgules | +| `ALLOW_SIGNUP` | Autorise ou non l'inscription lorsqu'aucune liste d'autorisation n'est configurée ; vaut `true` par défaut | + +L'ordre d'évaluation est le suivant : + +1. L'adresse e-mail correspond à `ALLOWED_EMAILS` — autorisé. +2. Ou son domaine correspond à `ALLOWED_EMAIL_DOMAINS` — autorisé. +3. Aucune liste d'autorisation n'est configurée et `ALLOW_SIGNUP=true` — autorisé. +4. Sinon, si l'adresse e-mail a une invitation à un espace de travail en attente et non expirée — autorisé. +5. Sinon — refusé. + +Configurations courantes : + +```dotenv +# Domaine de l'entreprise et utilisateurs invités +ALLOW_SIGNUP=false +ALLOWED_EMAIL_DOMAINS=company.com + +# Admettre aussi un collaborateur externe +ALLOWED_EMAILS=partner@example.net +``` + + +Les deux listes d'autorisation fonctionnent aussi comme une liste d'exceptions explicite lorsque `ALLOW_SIGNUP=false`. + + +## Invitations et restrictions d'inscription + +Une invitation à un espace de travail en attente et non expirée permet à son adresse e-mail de créer un compte même lorsque `ALLOW_SIGNUP=false` ou que l'adresse ne correspond ni à `ALLOWED_EMAILS` ni à `ALLOWED_EMAIL_DOMAINS`. Cette exception s'applique aussi lorsque `ALLOW_SIGNUP=true` avec une liste d'autorisation configurée : les listes d'autorisation ne constituent pas une barrière stricte contre les utilisateurs invités. + +Les utilisateurs existants peuvent continuer à se connecter. Les nouveaux utilisateurs qui ne correspondent à aucune entrée d'une liste d'autorisation et ne bénéficient pas d'une inscription ouverte ont besoin d'une invitation en attente et non expirée ; les invitations absentes, expirées, acceptées, refusées ou révoquées ne donnent pas le droit de s'inscrire. L'invitation est vérifiée lors de la demande d'un code de connexion, puis à nouveau lors de la création du compte, y compris avec la connexion Google. + +Vous n'avez pas besoin d'ajouter les invités ordinaires à `ALLOWED_EMAILS` ni de redémarrer le serveur. L'invitation doit toujours être acceptée via le flux d'invitation habituel pour rejoindre l'espace de travail. + +La révocation d'une invitation ne supprime pas un compte déjà créé grâce à elle et n'empêche pas ce compte existant de se connecter. + +## Durée de vie des sessions + +Les sessions sont glissantes. La durée de vie ci-dessous est une limite d'**inactivité**, et non un compte à rebours depuis la connexion : dès qu'il reste moins de la moitié de cette durée à une session, la requête suivante la réémet avec une durée de vie complète, si bien qu'un compte utilisé en continu n'est jamais déconnecté selon un calendrier fixe. Il n'y a pas de plafond absolu. + +Cette limite est asymétrique. Une session n'est réémise qu'une fois passée la moitié de sa durée de vie ; une session utilisée pour la dernière fois alors qu'il lui restait plus de la moitié de sa durée de vie n'est donc pas prolongée — la période d'inactivité toujours tolérée correspond à la moitié de la valeur ci-dessous, et non à sa totalité. + +Ajustez-la avec `AUTH_TOKEN_TTL`, qui accepte une durée Go ou un nombre entier positif de secondes : + +```dotenv +AUTH_TOKEN_TTL=720h +``` + +La valeur minimale est `60s` ; toute valeur plus courte est ramenée à ce minimum et un avertissement est journalisé au démarrage. En dessous, la cadence de renouvellement que le serveur en déduit passerait sous le plancher que les clients lui appliquent, et les clients vérifieraient le renouvellement moins souvent que la session ne peut survivre. + +Redémarrez le service API après l'avoir modifiée. La valeur s'applique aux sessions émises ou réémises ensuite — comme les sessions sont glissantes, une session existante adopte la nouvelle durée de vie lors de sa prochaine réémission. + +## Étapes suivantes + +- [Variables d'environnement](/environment-variables) — la référence complète des variables. +- [Authentification et jetons](/auth-tokens) — sessions de connexion et types de jetons. +- [Membres et rôles](/members-roles) — invitations et rôles. diff --git a/apps/docs/content/docs/auth-setup.ja.mdx b/apps/docs/content/docs/auth-setup.ja.mdx index 97dfbea0c92..01f41c3e74b 100644 --- a/apps/docs/content/docs/auth-setup.ja.mdx +++ b/apps/docs/content/docs/auth-setup.ja.mdx @@ -111,14 +111,14 @@ Google Console と `GOOGLE_REDIRECT_URI` のアドレスは、プロトコル、 1. メールアドレスが `ALLOWED_EMAILS` に一致すれば許可。 2. またはドメインが `ALLOWED_EMAIL_DOMAINS` に一致すれば許可。 -3. どちらにも一致せず、`ALLOW_SIGNUP=false` なら拒否。 -4. `ALLOW_SIGNUP=true` でも、いずれかの allowlist が設定されていて一致しなければ、やはり拒否。 -5. allowlist が一つも設定されておらず、`ALLOW_SIGNUP=true` なら許可。 +3. allowlist が一つも設定されておらず、`ALLOW_SIGNUP=true` なら許可。 +4. それ以外でも、そのメールアドレスに保留中かつ有効期限内のワークスペース招待があれば許可。 +5. それ以外は拒否。 よくある設定: ```dotenv -# 会社ドメインのみ許可 +# 会社ドメインと招待ユーザーを許可 ALLOW_SIGNUP=false ALLOWED_EMAIL_DOMAINS=company.com @@ -132,12 +132,13 @@ ALLOWED_EMAILS=partner@example.net ## 招待とサインアップ制限 -招待はサインアップ制限を自動的には迂回しません: +保留中かつ有効期限内のワークスペース招待があれば、`ALLOW_SIGNUP=false` の場合や、メールアドレスが `ALLOWED_EMAILS` または `ALLOWED_EMAIL_DOMAINS` に一致しない場合でもアカウントを作成できます。この例外は `ALLOW_SIGNUP=true` で allowlist が設定されている場合にも適用されます。allowlist は有効な招待を持つユーザーを拒否する境界ではありません。 -- 相手がすでに Multica アカウントを持っている場合は、ログインして招待を受諾できます。 -- まだアカウントがない場合は、そのメールアドレスが上記のサインアップルールを満たす必要があります。 +既存ユーザーは引き続きログインできます。新規ユーザーが allowlist に一致せず、自由な登録も許可されていない場合、保留中かつ有効期限内の招待が必要です。招待が存在しない場合や、期限切れ、受諾済み、辞退済み、取り消し済みの場合は登録を許可しません。招待はログインコードの要求時とアカウント作成時に確認され、Google ログインによるアカウント作成にも適用されます。 -オープンなサインアップを無効にしたインスタンスで新しいメンバーを招待するときは、先に相手のメールアドレスを `ALLOWED_EMAILS` に追加してください。アカウントが作成され、招待が受諾されたら、エントリを削除して構いません。 +通常の招待ユーザーを `ALLOWED_EMAILS` に追加したり、サーバーを再起動したりする必要はありません。ワークスペースに参加するには、アカウント作成後も通常の招待フローで招待を受諾する必要があります。 + +招待を取り消しても、その招待を使って作成済みのアカウントは削除されず、そのアカウントのログインも禁止されません。 ## セッション有効期間 diff --git a/apps/docs/content/docs/auth-setup.ko.mdx b/apps/docs/content/docs/auth-setup.ko.mdx index 1f69c3250ee..85e18474c8b 100644 --- a/apps/docs/content/docs/auth-setup.ko.mdx +++ b/apps/docs/content/docs/auth-setup.ko.mdx @@ -111,14 +111,14 @@ Google Console과 `GOOGLE_REDIRECT_URI`의 주소는 프로토콜, 포트, 마 1. 이메일이 `ALLOWED_EMAILS`와 일치하면 허용합니다. 2. 또는 도메인이 `ALLOWED_EMAIL_DOMAINS`와 일치하면 허용합니다. -3. 어느 것과도 일치하지 않고 `ALLOW_SIGNUP=false`이면 거부합니다. -4. `ALLOW_SIGNUP=true`여도 하나 이상의 allowlist가 설정되어 있고 일치하지 않으면 거부합니다. -5. allowlist가 없고 `ALLOW_SIGNUP=true`이면 허용합니다. +3. allowlist가 없고 `ALLOW_SIGNUP=true`이면 허용합니다. +4. 그 외에도 해당 이메일에 대기 중이며 만료되지 않은 워크스페이스 초대가 있으면 허용합니다. +5. 나머지는 거부합니다. 일반적인 설정: ```dotenv -# 회사 도메인만 허용 +# 회사 도메인과 초대받은 사용자 허용 ALLOW_SIGNUP=false ALLOWED_EMAIL_DOMAINS=company.com @@ -132,12 +132,13 @@ ALLOWED_EMAILS=partner@example.net ## 초대와 가입 제한 -초대는 가입 제한을 자동으로 우회하지 않습니다. +대기 중이며 만료되지 않은 워크스페이스 초대가 있으면 `ALLOW_SIGNUP=false`이거나 이메일이 `ALLOWED_EMAILS` 또는 `ALLOWED_EMAIL_DOMAINS`와 일치하지 않아도 계정을 만들 수 있습니다. 이 예외는 `ALLOW_SIGNUP=true`이고 allowlist가 설정된 경우에도 적용됩니다. allowlist는 유효한 초대를 받은 사용자를 차단하는 절대적인 경계가 아닙니다. -- 상대방에게 이미 Multica 계정이 있으면 로그인하고 초대를 수락할 수 있습니다. -- 상대방에게 계정이 없으면 이메일이 위의 가입 규칙을 만족해야 합니다. +기존 사용자는 계속 로그인할 수 있습니다. 새 사용자가 allowlist와 일치하지 않고 공개 가입도 허용되지 않으면 대기 중이며 만료되지 않은 초대가 필요합니다. 없거나 만료, 수락, 거절, 취소된 초대는 가입 권한을 부여하지 않습니다. 로그인 코드를 요청할 때와 계정을 만들 때 초대를 확인하며, Google 로그인으로 계정을 만드는 경우에도 적용됩니다. -공개 가입을 닫은 인스턴스에서 새 멤버를 초대할 때는 먼저 상대방 이메일을 `ALLOWED_EMAILS`에 추가하세요. 계정을 만들고 초대를 수락한 뒤 목록에서 다시 제거할 수 있습니다. +일반 초대 사용자를 `ALLOWED_EMAILS`에 추가하거나 서버를 다시 시작할 필요가 없습니다. 워크스페이스에 참여하려면 계정을 만든 뒤에도 정상 초대 흐름에서 초대를 수락해야 합니다. + +초대를 취소해도 해당 초대로 이미 만든 계정은 삭제되지 않으며 기존 계정의 로그인도 차단되지 않습니다. ## 로그인 유효 기간 diff --git a/apps/docs/content/docs/auth-setup.mdx b/apps/docs/content/docs/auth-setup.mdx index 7bed76984fa..52fe3e2178b 100644 --- a/apps/docs/content/docs/auth-setup.mdx +++ b/apps/docs/content/docs/auth-setup.mdx @@ -111,14 +111,14 @@ The evaluation order is: 1. The email matches `ALLOWED_EMAILS` — allow. 2. Or its domain matches `ALLOWED_EMAIL_DOMAINS` — allow. -3. With no match, if `ALLOW_SIGNUP=false` — reject. -4. With `ALLOW_SIGNUP=true` but any allowlist configured and no match — still reject. -5. With no allowlist configured and `ALLOW_SIGNUP=true` — allow. +3. With no allowlist configured and `ALLOW_SIGNUP=true` — allow. +4. Otherwise, if the email has a pending, unexpired workspace invitation — allow. +5. Otherwise — reject. Common configurations: ```dotenv -# Company domain only +# Company domain and invited users ALLOW_SIGNUP=false ALLOWED_EMAIL_DOMAINS=company.com @@ -132,12 +132,13 @@ Both allowlists also work as an explicit exception list when `ALLOW_SIGNUP=false ## Invitations and signup restrictions -Invitations do not automatically bypass signup restrictions: +A pending, unexpired workspace invitation allows its email address to create an account even when `ALLOW_SIGNUP=false` or the address does not match `ALLOWED_EMAILS` or `ALLOWED_EMAIL_DOMAINS`. This exception also applies when `ALLOW_SIGNUP=true` with an allowlist configured: allowlists are not a hard boundary against invited users. -- If the invitee already has a Multica account, they can sign in and accept the invitation. -- If they do not, their email must satisfy the signup rules above. +Existing users can continue to sign in. New users without a matching allowlist entry or open signup need a pending, unexpired invitation; missing, expired, accepted, declined, and revoked invitations do not grant signup permission. The invitation is checked when requesting a login code and again when creating the account, including Google sign-in. -To invite a new member on an instance with open signup disabled, add their email to `ALLOWED_EMAILS` first. Once the account is created and the invitation accepted, you can remove the entry again. +You do not need to add ordinary invitees to `ALLOWED_EMAILS` or restart the server. The invitation must still be accepted through the normal invitation flow to join the workspace. + +Revoking an invitation does not delete an account already created using it or prevent that existing account from signing in. ## Session lifetime diff --git a/apps/docs/content/docs/auth-setup.zh.mdx b/apps/docs/content/docs/auth-setup.zh.mdx index 2efeb51a574..bf1f5d30d43 100644 --- a/apps/docs/content/docs/auth-setup.zh.mdx +++ b/apps/docs/content/docs/auth-setup.zh.mdx @@ -111,14 +111,14 @@ Google Console 与 `GOOGLE_REDIRECT_URI` 中的地址必须完全一致,包括 1. 邮箱命中 `ALLOWED_EMAILS`,允许; 2. 或域名命中 `ALLOWED_EMAIL_DOMAINS`,允许; -3. 未命中时,如果 `ALLOW_SIGNUP=false`,拒绝; -4. `ALLOW_SIGNUP=true` 但设置了任意白名单且未命中,仍然拒绝; -5. 没有设置白名单且 `ALLOW_SIGNUP=true`,允许。 +3. 没有设置白名单且 `ALLOW_SIGNUP=true`,允许; +4. 否则,如果该邮箱有待接受且未过期的工作区邀请,允许; +5. 其他情况均拒绝。 常见配置: ```dotenv -# 只允许公司域名 +# 允许公司域名和受邀用户 ALLOW_SIGNUP=false ALLOWED_EMAIL_DOMAINS=company.com @@ -132,12 +132,13 @@ ALLOWED_EMAILS=partner@example.net ## 邀请与注册限制 -邀请不会自动绕过注册限制: +待接受且未过期的工作区邀请允许该邮箱创建账号,即使 `ALLOW_SIGNUP=false`,或邮箱未命中 `ALLOWED_EMAILS` 和 `ALLOWED_EMAIL_DOMAINS`。此例外也适用于 `ALLOW_SIGNUP=true` 且配置了白名单的情况:白名单不会拦截持有有效邀请的用户。 -- 对方已经有 Multica 账号时,可以登录并接受邀请; -- 对方还没有账号时,邮箱必须符合上面的注册规则。 +已有用户仍可登录。新用户如果未命中白名单且未开放注册,就需要待接受且未过期的邀请;缺失、已过期、已接受、已拒绝或已撤销的邀请不能授予注册许可。申请登录码时会检查邀请,创建账号时会再次检查,包括通过 Google 登录创建账号。 -在关闭开放注册的实例中邀请新成员时,先把对方邮箱加入 `ALLOWED_EMAILS`。账号创建并接受邀请后,可以再从名单中移除。 +普通受邀人无需再加入 `ALLOWED_EMAILS`,也无需重启服务器。账号创建后,仍需通过正常邀请流程接受邀请才能加入工作区。 + +撤销邀请不会删除通过该邀请创建的账号,也不会阻止该已有账号登录。 ## 登录有效期 diff --git a/apps/docs/content/docs/auth-tokens.fr.mdx b/apps/docs/content/docs/auth-tokens.fr.mdx new file mode 100644 index 00000000000..2c56b17bedb --- /dev/null +++ b/apps/docs/content/docs/auth-tokens.fr.mdx @@ -0,0 +1,101 @@ +--- +title: Authentification et jetons +description: Comprendre les sessions de connexion du navigateur, les jetons d'accès personnels et les identifiants temporaires qu'utilisent les agents pendant les exécutions. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Au quotidien, dans Multica, vous manipulez principalement deux types d'identifiants : les sessions de connexion du navigateur et les jetons d'accès personnels. Les sessions du navigateur servent au web et à Desktop ; les jetons d'accès personnels servent au CLI, au daemon, aux scripts et à l'API. + +## Sessions de connexion du navigateur + +Après une connexion par code de vérification envoyé par e-mail ou via Google, Multica stocke un JWT dans un cookie HttpOnly nommé `multica_auth`. Le navigateur l'envoie automatiquement ; JavaScript ne peut pas le lire directement. + +Par défaut, une session dure 30 jours, mais il ne s'agit pas d'un compte à rebours depuis la connexion : une session utilisée en continu est réémise avant son expiration, si bien que rester connecté n'oblige pas à se reconnecter à intervalles réguliers. Elle n'est réémise qu'une fois passée la moitié de sa durée de vie ; une session inutilisée peut donc prendre fin moins de 30 jours après sa dernière utilisation. Les administrateurs d'instances auto-hébergées peuvent ajuster cette durée de vie avec `AUTH_TOKEN_TTL` ; voir [Configuration de la connexion et de l'inscription](/auth-setup#durée-de-vie-des-sessions). La déconnexion efface les cookies d'authentification et CSRF du navigateur courant. + +Les cookies du navigateur ne sont pas faits pour être copiés dans des scripts ou dans le CLI ; utilisez un jeton d'accès personnel pour accéder à Multica depuis un terminal. + +## Jetons d'accès personnels + +Un jeton d'accès personnel (PAT) commence par `mul_` et représente votre compte. Il peut accéder à tous les espaces de travail et à toutes les API auxquels vous avez accès : protégez-le comme un mot de passe. + +Lorsque vous créez un jeton dans **Paramètres → Jetons d'API**, vous indiquez un nom et choisissez une expiration de 30 jours, 90 jours ou 1 an, ou « Sans expiration » ; 90 jours est présélectionné. Le jeton complet n'est affiché qu'une seule fois ; une fois la boîte de dialogue fermée, Multica ne conserve que : + +- le hachage du jeton ; +- les premiers caractères, pour l'identifier ; +- le nom, la date de création, la date d'expiration et la date de dernière utilisation. + +La valeur complète ne peut pas être récupérée. Si vous la perdez, révoquez l'ancien jeton et créez-en un nouveau. + + +Ne mettez pas de PAT dans des dépôts, des tâches, des commentaires, des captures d'écran ou des journaux, et ne le passez pas directement dans des commandes shell qui sont conservées. + + +## Le CLI et les PAT + +Lorsque vous exécutez `multica login`, le CLI effectue la connexion via le navigateur, puis crée un PAT valable 90 jours et l'enregistre dans le fichier de configuration du profil courant : + +```text +~/.multica/config.json +~/.multica/profiles//config.json +``` + +Le daemon se connecte à Multica avec ce même PAT. Un PAT `mul_` doté d'une date d'expiration est renouvelé automatiquement lorsqu'il lui reste moins de 7 jours, ce qui le prolonge jusqu'à 90 jours à compter de ce moment. Un renouvellement en échec laisse le jeton inchangé ; une fois un jeton expiré ou révoqué, exécutez de nouveau `multica login`. + +Sur une machine sans navigateur, créez d'abord un PAT sur le web, puis laissez le CLI vous le demander de manière sécurisée : + +```bash +multica login --token +``` + +## Utiliser un PAT dans les requêtes API + +Placez le PAT dans l'en-tête `Authorization` : + +```bash +export MULTICA_TOKEN='mul_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + +curl https://api.multica.ai/api/me \ + -H "Authorization: Bearer $MULTICA_TOKEN" +``` + +Les appels d'API au niveau de l'espace de travail nécessitent aussi l'espace de travail, selon ce qu'exige le point de terminaison : + +```bash +curl https://api.multica.ai/api/issues \ + -H "Authorization: Bearer $MULTICA_TOKEN" \ + -H "X-Workspace-ID: $MULTICA_WORKSPACE_ID" +``` + +Dans les scripts, récupérez le jeton depuis un gestionnaire de secrets ou une variable d'environnement protégée — ne l'écrivez jamais en dur. Sur les instances auto-hébergées, remplacez le domaine par votre propre adresse d'API publique. + +## Déconnexion et révocation + +`multica auth logout` supprime uniquement le PAT enregistré dans le profil CLI courant ; la déconnexion sur le web supprime uniquement les cookies du navigateur courant. Aucune des deux ne révoque le jeton d'accès personnel côté serveur. + +Si un jeton a pu fuiter, révoquez-le immédiatement dans **Paramètres → Jetons d'API**. Une fois révoqué, le PAT ne peut plus jamais être utilisé, et les autres machines et scripts qui l'avaient enregistré perdent également l'accès. + +## Jetons temporaires pour les exécutions d'agents + +Lorsque le daemon prend en charge une exécution, le serveur crée pour celle-ci un jeton temporaire préfixé par `mat_`. Il est lié à l'utilisateur, à l'espace de travail, à l'agent et à l'exécution en cours, reste valide 24 heures au maximum et est supprimé à la fin de l'exécution. + +Le daemon injecte ce jeton temporaire dans l'outil de codage IA au lieu de transmettre le PAT de l'utilisateur à l'agent. Les requêtes effectuées par l'agent sont donc enregistrées comme des actions de l'agent, et le jeton ne permet pas d'accéder aux opérations sensibles réservées aux utilisateurs ou aux propriétaires. + +Ces jetons sont créés automatiquement par le serveur ; les utilisateurs n'ont jamais besoin de les enregistrer ni de les gérer. + +## Autres identifiants machine + +Le serveur reconnaît également deux identifiants utilisés dans des scénarios internes ou gérés : + +| Préfixe | Rôle | Géré par | +| --- | --- | --- | +| `mcn_` | Connexions Multica Cloud Node | Multica Cloud Fleet | +| `mdt_` | Protocole d'authentification du daemon limité à un espace de travail | Flux internes du serveur | + +Les installations Cloud, auto-hébergées et Desktop classiques n'ont jamais besoin de les créer manuellement. Le CLI et le daemon côté utilisateur utilisent toujours des PAT `mul_` ; ne construisez pas vous-même de jetons avec d'autres préfixes. + +## Étapes suivantes + +- [Utiliser le CLI](/cli) — `login`, `auth status` et configuration des profils. +- [Daemon et runtimes](/daemon-runtimes) — où a lieu le renouvellement des PAT. +- [Configuration de la connexion et de l'inscription](/auth-setup) — configuration côté serveur de la durée de vie des sessions et des restrictions d'inscription. diff --git a/apps/docs/content/docs/autopilots.fr.mdx b/apps/docs/content/docs/autopilots.fr.mdx new file mode 100644 index 00000000000..d9d013a2ab6 --- /dev/null +++ b/apps/docs/content/docs/autopilots.fr.mdx @@ -0,0 +1,160 @@ +--- +title: Automatisations +description: Confiez automatiquement le travail récurrent aux agents, selon une planification ou depuis un webhook. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Les automatisations prennent en charge le travail qui revient sans cesse — un résumé quotidien de l'avancement, une vérification périodique des dépendances, ou un agent lancé par un événement provenant d'un système externe. + +Chaque automatisation enregistre une procédure, un assigné et un ou plusieurs déclencheurs. Lorsqu'elle est déclenchée, Multica crée une tâche ou exécute directement l'agent, et conserve une trace de chaque exécution. + +## Créer une automatisation + +Ouvrez **Automatisation** dans la barre latérale, choisissez un modèle ou partez de zéro, puis configurez : + +- **Nom** : ce dont cette automatisation est responsable ; +- **Procédure** : l'objectif, le contexte, les contraintes et les étapes que l'agent lit à chaque exécution ; +- **Assigné à** : un agent ou un squad ; +- **Projet** : facultatif ; place les tâches créées automatiquement dans un projet donné ; +- **Mode de sortie** : créer une tâche, ou exécution seule ; +- **Abonnés** : les membres à notifier après la création automatique d'une tâche ; +- **Déclencheurs** : une planification ou un webhook. + +Une automatisation est activée par défaut après l'enregistrement ; **Exécuter maintenant** lance manuellement le flux complet une fois, à tout moment. + +## Choisir un mode de sortie + +| Mode | Comportement | Idéal pour | +|---|---|---| +| **Créer une tâche** | Chaque déclenchement crée d'abord une tâche, puis l'assigne à l'agent ou au squad assigné ; la discussion, le statut et les enregistrements d'exécution se trouvent tous sur la tâche. | Le travail que l'équipe doit relire, confirmer ou suivre. | +| **Exécution seule** | Crée directement une exécution, sans tâche ; les résultats ne sont visibles que dans l'historique des exécutions de l'automatisation. | Les exécutions d'arrière-plan qui ne nécessitent aucune trace collaborative. | + +Le mode **Créer une tâche** utilise la même file d'exécution que les tâches ordinaires : lorsque le runtime est hors ligne, la tâche est tout de même créée et l'exécution attend que le runtime revienne en ligne. + +Le mode **Exécution seule** exige que le runtime soit disponible au moment du déclenchement ; sinon, l'exécution apparaît comme « Ignorée », et aucune tâche en attente n'est laissée derrière. + +## Exécuter selon une planification + +L'éditeur de planification vous permet de choisir l'heure d'exécution, les jours de répétition, la plage horaire et le fuseau horaire, et affiche un aperçu des prochaines exécutions. Une automatisation peut avoir plusieurs planifications ; l'activation ou la désactivation d'un déclencheur individuel se fait avec la commande CLI `autopilot trigger-update` — voir [Utiliser le CLI](/cli) pour les paramètres. + +Lorsque vous avez besoin de règles plus complexes, modifiez directement le cron standard à 5 champs : + +```text +minute hour day month weekday +``` + +Par exemple : + +| Cron | Fuseau horaire | Signification | +|---|---|---| +| `0 9 * * 1-5` | `Asia/Shanghai` | 9 h 00 en semaine | +| `*/30 * * * *` | `UTC` | Toutes les 30 minutes | +| `0 3 * * *` | `UTC` | Tous les jours à 3 h 00 | + +Le cron n'a pas de champ pour les secondes, et les fuseaux horaires utilisent des noms IANA comme `Asia/Shanghai`. Avant d'enregistrer, comparez le résultat avec les horaires « Prochaines exécutions » affichés sur la page. + +![L'éditeur d'automatisation : procédure, paramètres de planification et aperçu des prochaines exécutions](/images/docs/autopilot-schedule.webp) + +## Exécuter depuis un webhook + +Après l'ajout d'un déclencheur webhook, Multica génère une URL unique. Envoyez-lui un objet ou un tableau JSON pour déclencher l'automatisation : + +```bash +curl -X POST "$MULTICA_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: demo-001" \ + -d '{"event":"build.completed","eventPayload":{"status":"success"}}' +``` + +La charge utile est enregistrée dans les enregistrements de livraison et d'exécution, et transmise à l'agent. En mode création de tâche, elle est également ajoutée à la description de la tâche. + +Contraintes applicables aux requêtes webhook : + +- le corps doit être un objet ou un tableau JSON valide, de 256 Kio au maximum ; +- `Idempotency-Key` évite les exécutions en double lorsque l'expéditeur réessaie ; les livraisons GitHub sont en outre dédupliquées par `X-GitHub-Delivery` ; +- sans clé d'idempotence stable, Multica ne peut pas garantir que des requêtes répétées ne s'exécutent qu'une seule fois ; +- lorsque le déclencheur est désactivé ou que l'événement ne correspond pas, la livraison est enregistrée comme ignorée et aucune exécution n'est créée. + +### Filtres d'événements + +Lorsqu'une même source envoie plusieurs types d'événements, ajoutez des filtres d'événements au déclencheur. Chaque ligne contient un nom d'événement et une liste facultative d'actions ; une exécution est déclenchée si une ligne correspond, et laisser toutes les lignes vides accepte tous les événements. + +Par exemple, avec `workflow_run` comme nom d'événement et `completed, failed` comme actions, seuls ces deux types de résultats `workflow_run` sont acceptés. Multica reconnaît l'événement et l'action à partir des en-têtes de requête et des champs de charge utile courants, notamment l'en-tête `X-GitHub-Event` de GitHub et le champ `action` du corps. + +### Protéger l'URL du webhook + + +Le jeton contenu dans l'URL du webhook sert d'identifiant d'appel. Ne mettez pas l'URL complète dans des dépôts publics, des tâches ou des captures d'écran. En cas de fuite de l'URL, cliquez sur le bouton **Renouveler l'URL** situé à côté et mettez immédiatement à jour l'expéditeur ; l'ancienne URL cesse de fonctionner aussitôt. + + +Seuls le créateur de l'automatisation, les propriétaires/administrateurs de l'espace de travail et les collaborateurs ayant reçu un accès peuvent voir l'URL complète. Par défaut, l'interface masque le jeton dans l'URL, et la copie ne nécessite pas de l'afficher ; cliquez sur l'URL ou sur l'icône en forme d'œil pour voir l'adresse complète. + +### Référence des réponses du webhook + +Lors du débogage d'un expéditeur, utilisez ce tableau pour interpréter les réponses de Multica : + +| Statut HTTP | Statut de la réponse | Signification | +|---|---|---| +| 200 | `accepted` | Acceptée et une exécution a été créée ; renvoie les ID de la livraison et de l'exécution. | +| 200 | `skipped` | Acceptée, mais cette exécution a été ignorée (par exemple, le runtime est hors ligne en mode exécution seule) ; inclut la raison. | +| 200 | `ignored` | Aucune exécution créée : le déclencheur est désactivé, l'automatisation est en pause ou archivée, ou l'événement a été filtré ; le champ `reason` en indique la cause. | +| 200 | `duplicate` | La clé d'idempotence correspond à une livraison existante ; renvoie l'ID de la livraison d'origine et n'exécute rien de nouveau. | +| 400 | Message d'erreur | Le corps est vide, n'est pas du JSON valide, ou n'est pas un objet/tableau JSON. | +| 401 | `rejected` | Le déclencheur a un secret de signature configuré, mais la requête n'a pas de signature ou la signature ne correspond pas. | +| 404 | Message d'erreur | Le jeton de l'URL est invalide ou a été renouvelé. | +| 413 | Message d'erreur | Le corps dépasse 256 Kio. | +| 429 | Message d'erreur | Trop de requêtes ; réessayez plus tard en respectant l'en-tête de réponse `Retry-After`. | +| 500 | Message d'erreur | Erreur interne de Multica ; l'expéditeur peut réessayer plus tard. | + +Les cas ignorés au niveau métier — mise en pause, archivage et filtrage d'événements — renvoient 200 plutôt qu'un code 4xx, afin que les expéditeurs ne réessaient pas indéfiniment. + +Ordre de déduction de l'événement et de l'action : + +1. si le corps contient un champ `event` de type chaîne, il est utilisé directement ; +2. sinon, l'en-tête de requête `X-GitHub-Event`, combiné avec le champ `action` du corps sous la forme `github..` ; +3. puis l'en-tête de requête `X-Gitlab-Event` ; +4. puis l'en-tête de requête `X-Event-Type` ; +5. puis les champs `event`, `type` et `action` du corps ; +6. si tous sont absents, l'événement est enregistré comme `webhook.received`. + +## Consulter les exécutions et les livraisons + +L'historique des exécutions affiche la source du déclenchement, l'heure, le statut, la tâche ou l'exécution liée, et la raison d'un échec ou d'une exécution ignorée. Les déclencheurs webhook conservent en plus des enregistrements de livraison distincts, avec l'événement analysé, la réponse, les informations de déduplication et la raison de l'échec. + +Une livraison de webhook entièrement traitée peut être rejouée depuis sa vue détaillée. Un rejeu crée une nouvelle livraison et une nouvelle exécution sans réécrire l'enregistrement d'origine ; les livraisons dont la vérification de signature a échoué ou qui sont encore en file d'attente ne peuvent pas être rejouées, et les rejeux ne participent pas à la déduplication. + +## Échecs, mise en pause et suppression + +En mode exécution seule, une exécution en échec n'est pas relancée automatiquement ; la planification suivante se déclenche tout de même comme prévu. Le mode création de tâche produit des exécutions de tâche ordinaires, et les échecs d'infrastructure suivent les règles décrites dans [Exécutions](/tasks#échecs-et-relances-automatiques). + +Multica vérifie périodiquement si les exécutions récentes échouent de façon répétée : lorsque les 7 derniers jours comptent au moins 50 exécutions terminées ou en échec avec un taux d'échec de 90 %, le système met l'automatisation en pause et notifie son créateur ; corrigez la cause, puis reprenez-la manuellement. + +La mise en pause manuelle arrête les planifications, les webhooks et **Exécuter maintenant**. La suppression est en réalité un archivage : les déclenchements futurs s'arrêtent, et l'historique des exécutions et des livraisons est conservé. + +## Permissions + +- Tout membre de l'espace de travail peut créer des automatisations ; +- le créateur et les propriétaires/administrateurs de l'espace de travail peuvent modifier, exécuter, supprimer et gérer les déclencheurs ; +- le créateur et les propriétaires/administrateurs peuvent accorder l'accès de gestion à des collaborateurs ; +- les collaborateurs peuvent modifier, exécuter et gérer les déclencheurs, mais ne peuvent pas accorder l'accès à d'autres personnes ; +- pouvoir gérer une automatisation ne garantit pas de pouvoir exécuter son agent — le paramètre Accès de l'agent continue de s'appliquer. + +## Utiliser le CLI + +```bash +multica autopilot get --output json +multica autopilot trigger +multica autopilot runs +multica autopilot trigger-rotate-url +``` + +`autopilot get` définit par défaut `webhook_token`, `webhook_path` et `webhook_url` à `null`, et renvoie à la place `has_webhook_token` et `webhook_token_hint`. N'ajoutez `--show-secrets` que lorsque vous avez délibérément besoin de l'identifiant réel ; le CLI affiche alors un avertissement sur stderr afin que le JSON transmis par pipe reste valide. + +Consultez [Utiliser le CLI](/cli) pour la liste complète des paramètres. + +## Étapes suivantes + +- [Assigner des tâches aux agents](/assigning-issues) — l'assignation et la fenêtre de confirmation. +- [Boîte de réception et abonnements](/inbox) — où arrivent les notifications d'exécution. +- [Exécutions](/tasks) — états des exécutions et gestion des échecs. diff --git a/apps/docs/content/docs/channels.fr.mdx b/apps/docs/content/docs/channels.fr.mdx new file mode 100644 index 00000000000..d881f510152 --- /dev/null +++ b/apps/docs/content/docs/channels.fr.mdx @@ -0,0 +1,114 @@ +--- +title: Intégrations de messagerie +description: Connectez les agents Multica à Feishu, Lark, Slack, DingTalk, WeCom ou Telegram et utilisez-les depuis les outils de messagerie que votre équipe utilise déjà. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Les intégrations de messagerie permettent à l'équipe de poser des questions à un agent, de le @mentionner dans une conversation de groupe ou de créer des tâches depuis la fenêtre de conversation, sans ouvrir Multica. + +Feishu/Lark, Slack, DingTalk, WeCom et Telegram sont pris en charge aujourd'hui. Ils partagent les mêmes mécanismes de session, d'identité et d'exécution, mais s'installent différemment. + +## Choisir une plateforme + +| | Feishu / Lark | Slack | DingTalk | WeCom | Telegram | +|---|---|---|---|---|---| +| Installation | Générez un QR code dans Multica et autorisez-le en le scannant avec Feishu | Créez une application dans Slack, puis collez deux jetons dans Multica | Créez une application interne et un robot en mode Stream, puis collez son AppKey et son AppSecret dans Multica | Créez un bot intelligent avec la connexion longue activée dans la console d'administration WeCom, puis collez son ID de bot et son secret dans Multica | Créez un bot avec @BotFather, puis collez son jeton dans Multica | +| Messages directs à l'agent | Pris en charge | Pris en charge | Pris en charge | Pris en charge | Pris en charge | +| Groupes ou canaux | Déclenché en @mentionnant le bot | Déclenché en @mentionnant le bot | Déclenché en @mentionnant le bot | Déclenché en @mentionnant le bot | Déclenché en @mentionnant le bot ou en lui répondant | +| Créer des tâches | Commande de message `/issue` ; crée la tâche à partir de votre saisie telle quelle | Commande slash `/issue` ; l'agent rédige la description avant de créer la tâche | Commande de message `/issue` ; crée la tâche à partir de votre saisie telle quelle | Commande de message `/issue` ; crée la tâche à partir de votre saisie telle quelle | Commande de message `/issue` ; crée la tâche à partir de votre saisie telle quelle | +| Démarrer une nouvelle discussion | `/new [message]` | Message direct : `/new [message]` ; canal/fil : `@Multica /new [message]` | `/new [message]` | `/new [message]` | `/new [message]` | +| Effacer le contexte de la discussion en cours | `/clear [message]` | Message direct : `/clear [message]` ; canal/fil : `@Multica /clear [message]` | `/clear [message]` | `/clear [message]` | `/clear [message]` | +| Connexion | Connexion longue de la plateforme | Socket Mode | Mode Stream | Connexion longue de la plateforme | Long polling `getUpdates` | + +Les nouvelles connexions ne sont actuellement ouvertes qu'à Feishu en Chine continentale ; les connexions Lark internationales existantes continuent de fonctionner et peuvent toujours être gérées. + +Chaque bot est associé à un seul agent Multica. Pour utiliser plusieurs agents sur la même plateforme de messagerie, connectez un bot distinct pour chacun. + +DingTalk, WeCom et Telegram sont [maintenus par la communauté](/community-maintained) : ils sont inclus dans chaque version, mais sans SLA de support officiel. Signalez les problèmes dans les [issues GitHub](https://github.com/multica-ai/multica/issues). + +WeCom traite aujourd'hui les messages texte. Les messages vocaux, les images et les fichiers reçoivent une courte réponse qui l'explique et ne sont pas transmis à l'agent. + +Telegram transmet à l'agent les photos, fichiers, vidéos et contenus audio sous forme de pièces jointes, et renvoie dans la conversation les fichiers produits par l'agent, lorsque le stockage des pièces jointes est configuré sur le serveur ; sans cela, les médias reçoivent, comme auparavant, une courte réponse indiquant qu'ils ne sont pas pris en charge. Les stickers et autres messages qui ne sont pas des fichiers reçoivent cette même réponse et ne sont pas transmis à l'agent. + +Guides pas à pas : + +- [Intégration du bot Feishu / Lark](/lark-bot-integration) +- [Intégration du bot Slack](/slack-bot-integration) +- [Intégration du bot DingTalk](/dingtalk-bot-integration) +- [Intégration du bot Telegram](/telegram-bot-integration) + +## Traitement d'un message + +1. Multica identifie l'espace de travail et l'agent auxquels le bot est rattaché. +2. Dans un groupe ou un canal, seuls les messages qui @mentionnent explicitement le bot sont traités ; les messages directs ne nécessitent aucune mention. +3. Multica vérifie l'association du compte de l'expéditeur et son appartenance à l'espace de travail. +4. Le message rejoint une session de [discussion avec l'agent](/chat), et une exécution est créée. +5. La réponse de l'agent est renvoyée dans le message direct ou le fil d'origine. + +Les messages de canal qui ne @mentionnent pas le bot ne déclenchent jamais l'agent et ne sont pas ajoutés au contexte de sa conversation. + +Les messages ordinaires suivent ce flux. `/issue` est une commande, pas un tour de discussion : Multica publie le résultat sur la plateforme d'origine, mais n'ajoute pas la commande aux discussions Multica. La commande slash native de Slack passe toujours par son propre flux asynchrone de création de tâche. + +## Contrôle des conversations + +`/new` crée une nouvelle discussion Multica et y achemine les messages suivants de cette conversation externe. `/new ` crée la discussion et utilise le message comme premier tour. La discussion précédente reste enregistrée et utilisable dans Multica. + +`/clear` reste dans la discussion Multica en cours, mais démarre un nouveau contexte visible par l'agent. L'historique complet de la discussion reste disponible dans Multica, tandis que l'agent ne peut plus récupérer les messages antérieurs à cette limite. `/clear ` utilise le message comme premier tour du nouveau contexte ; un `/clear` seul s'applique au prochain vrai message. + +Sur Slack, `/new` et `/clear` sont des commandes slash natives dans un message direct. La charge utile d'une commande slash native n'identifie pas le fil d'un canal : utilisez donc `@Multica /new [message]` ou `@Multica /clear [message]` dans le fil cible. + +## Isolation des sessions + +- Feishu/Lark sépare les sessions par conversation ; les messages suivants d'une même conversation poursuivent la même session. +- Slack sépare les messages directs par canal ; dans un canal, chaque fil a sa propre session. +- DingTalk sépare les sessions par conversation ; chaque message direct ou groupe poursuit sa propre session. +- WeCom sépare les sessions par conversation ; chaque message direct ou conversation de groupe poursuit sa propre session. +- Telegram sépare les sessions par conversation ; les sujets de forum sont isolés par sujet. + +Dans un canal, chaque relance nécessite toujours une nouvelle @mention. L'agent ne reçoit que les messages qui lui sont adressés : il ne lit jamais automatiquement tout l'historique du canal. + +## Association de compte + +La première fois qu'un membre écrit au bot, il reçoit un lien d'association de compte. Une fois qu'il s'est connecté à Multica, son compte sur la plateforme est associé à son appartenance à l'espace de travail actuel. + +Multica n'exécute l'agent qu'une fois l'association terminée. Chaque message revérifie l'association du compte et l'appartenance à l'espace de travail ; après avoir quitté un espace de travail, on ne peut plus l'atteindre via le bot. + + +L'association de compte confirme uniquement l'identité de l'expéditeur. Les autres membres de la plateforme de messagerie ne sont pas ajoutés automatiquement à l'espace de travail Multica. + + +## Gérer les connexions + +Les propriétaires et administrateurs de l'espace de travail peuvent connecter ou déconnecter des bots ; pour les bots Feishu/Lark, la connexion et la déconnexion sont aussi ouvertes au propriétaire de l'agent. Les membres ordinaires peuvent consulter les intégrations connectées et utiliser les agents auxquels ils ont accès. + +Après la déconnexion, le bot ne reçoit plus de nouveaux messages. Les conversations Multica et les historiques d'exécution existants sont conservés. + +## Auto-hébergement + +Un déploiement auto-hébergé doit configurer une clé de chiffrement de 32 octets pour chaque plateforme avant que Multica n'ouvre le point d'entrée de connexion correspondant : + +```dotenv +MULTICA_LARK_SECRET_KEY= +MULTICA_SLACK_SECRET_KEY= +MULTICA_DINGTALK_SECRET_KEY= +MULTICA_WECOM_SECRET_KEY= +MULTICA_TELEGRAM_SECRET_KEY= +``` + +Ces clés chiffrent les identifiants de bot stockés. Consultez [Variables d'environnement](/environment-variables) pour savoir comment les générer, les stocker et les renouveler. Multica Cloud est déjà configuré. + +Le seul chemin sortant de WeCom est le WebSocket détenu par un processus ; ce qu'il advient d'une réponse produite sur une *autre* réplique dépend donc du relais temps réel : + +- **Mode de relais sharded ou dual** (`REDIS_URL` défini — le mode par défaut avec Redis) : la réponse est transmise à la réplique qui détient la connexion, puis livrée. WeCom sur plusieurs répliques est pris en charge. +- **Mode de relais legacy, ou sans Redis** : la réponse est abandonnée. Dans cette configuration, exécutez le backend avec WeCom activé sur une seule réplique. + +Quel que soit le mode, une réponse produite alors qu'*aucune* réplique ne détient de connexion active (toutes en cours de reconnexion) n'est pas livrée. Elle est comptabilisée dans `multica_wecom_outbound_dropped_total{reason="no_live_connection"}` par la réplique qui l'a routée, ce qui permet de mesurer l'ampleur de cette fenêtre ; si cette perte est inacceptable, une réplique unique reste le déploiement le plus prudent. + +## Étapes suivantes + +- [Intégration du bot Feishu / Lark](/lark-bot-integration) — connecter un agent en scannant un QR code. +- [Intégration du bot Slack](/slack-bot-integration) — créer une application Slack et coller ses jetons dans Multica. +- [Intégration du bot DingTalk](/dingtalk-bot-integration) — créer un robot en mode Stream et coller ses identifiants dans Multica. +- [Intégration du bot Telegram](/telegram-bot-integration) — créer un bot avec @BotFather et coller son jeton dans Multica. +- [Discussion](/chat) — le modèle de conversation et d'exécution derrière chaque bot. diff --git a/apps/docs/content/docs/channels.ja.mdx b/apps/docs/content/docs/channels.ja.mdx index 74215b6eb67..081ce7426b7 100644 --- a/apps/docs/content/docs/channels.ja.mdx +++ b/apps/docs/content/docs/channels.ja.mdx @@ -29,7 +29,7 @@ DingTalk、WeCom、Telegram は[コミュニティメンテナンス](/community WeCom が現在扱えるのはテキストメッセージのみです。音声、画像、ファイルのメッセージにはその旨の短い返信が返り、エージェントには渡されません。 -Telegram が現在扱えるのはテキストメッセージのみです。未対応のメディアにはその旨の短い返信が返り、エージェントには渡されません。 +Telegram は画像、ファイル、動画、音声を添付ファイルとしてエージェントに渡し、エージェントが生成したファイルをチャットに送り返します。どちらもサーバー側にオブジェクトストレージの設定が必要で、未設定の場合メディアには従来どおり非対応の短い返信が返ります。sticker などファイル以外のメッセージにも同じ返信が返り、エージェントには渡されません。 詳しい手順: diff --git a/apps/docs/content/docs/channels.ko.mdx b/apps/docs/content/docs/channels.ko.mdx index b2a3cce00e3..b83f71954df 100644 --- a/apps/docs/content/docs/channels.ko.mdx +++ b/apps/docs/content/docs/channels.ko.mdx @@ -29,7 +29,7 @@ DingTalk, WeCom, Telegram은 [커뮤니티에서 유지 관리](/community-maint WeCom은 현재 텍스트 메시지만 처리합니다. 음성, 이미지, 파일 메시지에는 안내 문구만 회신하며 에이전트로 전달하지 않습니다. -Telegram은 현재 텍스트 메시지만 처리합니다. 미지원 미디어에는 안내 문구만 회신하며 에이전트로 전달하지 않습니다. +Telegram은 이미지, 파일, 영상, 음성을 첨부파일로 에이전트에 전달하고, 에이전트가 만든 파일을 채팅으로 다시 보냅니다. 두 방향 모두 서버에 객체 스토리지가 설정되어 있어야 하며, 없으면 미디어에는 이전처럼 미지원 안내만 회신합니다. sticker 등 파일이 아닌 메시지에도 같은 안내를 회신하며 에이전트로 전달하지 않습니다. 자세한 단계는 다음 문서를 참고하세요. diff --git a/apps/docs/content/docs/channels.mdx b/apps/docs/content/docs/channels.mdx index 6bb7bf302c5..a7bcda5b60e 100644 --- a/apps/docs/content/docs/channels.mdx +++ b/apps/docs/content/docs/channels.mdx @@ -29,7 +29,7 @@ DingTalk, WeCom, and Telegram are [community-maintained](/community-maintained): WeCom handles text messages today. Voice, image, and file messages get a short reply explaining that, and are not passed to the agent. -Telegram handles text messages today. Unsupported media gets a short reply explaining that, and is not passed to the agent. +Telegram passes photos, files, video, and audio to the agent as attachments, and sends files the agent produces back into the chat, when the server has object storage configured; without it, media gets the same short unsupported reply as before. Stickers and other non-file messages get that reply and are not passed to the agent. Step-by-step guides: diff --git a/apps/docs/content/docs/channels.zh.mdx b/apps/docs/content/docs/channels.zh.mdx index 8106bc2dd91..e903cfa9cd7 100644 --- a/apps/docs/content/docs/channels.zh.mdx +++ b/apps/docs/content/docs/channels.zh.mdx @@ -29,7 +29,7 @@ import { Callout } from "fumadocs-ui/components/callout"; 企业微信目前只处理文字消息。语音、图片和文件消息会收到一句说明,不会转交给智能体。 -Telegram 目前只处理文字消息。不支持的媒体消息会收到一句说明,不会转交给智能体。 +Telegram 会把图片、文件、视频和音频作为附件转交给智能体,并把智能体生成的文件发回聊天;这两个方向都需要服务端配置对象存储,未配置时媒体消息仍像以前一样收到不支持提示。贴纸等非文件消息会收到同样的提示,不会转交给智能体。 详细步骤: diff --git a/apps/docs/content/docs/chat.fr.mdx b/apps/docs/content/docs/chat.fr.mdx new file mode 100644 index 00000000000..70293c349d0 --- /dev/null +++ b/apps/docs/content/docs/chat.fr.mdx @@ -0,0 +1,71 @@ +--- +title: "Discuter avec des agents" +description: "Conversation privée en tête-à-tête avec un agent, en dehors de toute tâche. L'agent démarre sans aucun contexte de tâche et ne crée jamais de tâches de lui-même — mais il agit avec vos accès à l'espace de travail lorsque vous le lui demandez explicitement." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Discussion convient aux sujets qui n'ont pas encore pris la forme d'une tâche claire : clarifier des idées, faire le point sur l'espace de travail, discuter d'une approche, ou confier d'abord un petit travail à un agent. + +**Une discussion est une conversation en tête-à-tête entre vous et un [agent](/agents)**, en dehors du tableau des [tâches](/issues). L'agent démarre **sans aucun contexte de tâche** — il ne regarde pas votre tableau et ne transformera pas la conversation en tâches de lui-même — et l'ensemble de la conversation est **entièrement privé** (personne d'autre dans l'[espace de travail](/workspaces), pas même les administrateurs, ne peut la voir). Elle convient pour discuter d'une approche avec un agent, faire un brainstorming ou poser une question qui ne relève d'aucune tâche. + +Une discussion n'est **liée à aucune tâche** : l'agent ne reçoit pas automatiquement la description et les commentaires d'une tâche, mais il peut toujours utiliser le CLI Multica pour consulter les espaces de travail, projets, tâches et skills dans la limite de ses autorisations. + +Une discussion peut aussi porter un contexte. Cliquez sur **+** en bas à gauche de la zone de saisie et choisissez **Contexte de projet** pour rattacher un [projet](/projects) à la conversation ; lorsque l'agent s'exécute, il reçoit la description, les dépôts et les autres ressources de ce projet. + +![Une discussion avec un agent : une question dont la réponse est organisée dans un tableau](/images/docs/chat-conversation.webp) + +## Démarrer une conversation + +1. Ouvrez **Discussion** dans la barre latérale. +2. Cliquez sur Nouvelle discussion et choisissez un agent que vous avez la permission d'exécuter. +3. Saisissez un message ; vous pouvez aussi joindre des images ou des fichiers. +4. Après l'envoi, Multica crée une exécution pour le message, et la réponse revient dans la conversation en cours. + +Si le runtime est hors ligne, le message attend son retour en ligne. Une fois l'agent archivé, ou si vous perdez la permission de l'exécuter, vous ne pouvez plus envoyer de nouveaux messages. + +Avant le premier message, Discussion affiche jusqu'à trois amorces de conversation configurées pour l'agent sélectionné. En choisir une copie son prompt complet dans la zone de saisie sans l'envoyer, pour que vous puissiez d'abord l'adapter. Les agents sans amorces configurées utilisent des exemples généraux localisés. + +## Discussion ou tâches + +| Besoin | Le plus adapté | +|---|---| +| Questions rapides, exploration d'une approche ou brouillons privés | **Discussion** | +| Un assigné, un statut, une priorité et un livrable clairs | **Tâche** | +| Permettre à vos coéquipiers de voir le contexte et de poursuivre la discussion | **Tâche** | +| Un coup d'œil rapide aux informations de l'espace de travail, sans besoin de garder une trace du travail | **Discussion** | + +Les discussions directes créées dans Multica ne sont visibles que par leur créateur ; les autres membres et les administrateurs de l'espace de travail ne peuvent pas les lire. Les conclusions que toute l'équipe doit voir doivent être consignées dans une tâche, une description de projet ou un skill. + +## Contexte sur plusieurs tours + +Une conversation tente de poursuivre la session d'origine dans l'outil de codage IA, afin que les messages suivants n'aient pas à répéter le contenu précédent. Multica stocke l'identifiant de session et achemine les exécutions suivantes vers un runtime qui a accès à cette session. + +Si la session locale n'existe plus, si le contexte ne peut pas être poursuivi, ou si l'erreur précédente ne doit pas être réutilisée, le runtime démarre une nouvelle session. L'historique des messages de la conversation est conservé dans tous les cas. + +## Suivre et arrêter une exécution + +Pendant l'exécution de l'agent, l'interface affiche la phase en cours et l'activité des outils. Dépliez **Afficher les détails** pour inspecter l'exécution en entier. + +Pour l'interrompre, cliquez sur le bouton Arrêter à côté de la zone de saisie. Le contenu déjà renvoyé dans la conversation est conservé ; la saisie pas encore traitée est restaurée dans l'éditeur lorsque c'est possible. + +## Organiser la liste des conversations + +Les conversations peuvent être renommées, épinglées, archivées et supprimées : + +- **Épingler** maintient les conversations fréquemment utilisées en haut de la liste ; +- **Archiver** déplace une conversation dans Archivées et la passe en lecture seule ; +- **Désarchiver** vous permet d'envoyer à nouveau des messages ; +- **Supprimer** retire définitivement la conversation et ses messages. + +Si une conversation est liée à un canal externe (comme Slack), l'archiver rompt cette liaison ; la désarchiver ne la rétablit pas — liez-la à nouveau. + + +La suppression d'une conversation est irréversible et arrête toutes les exécutions qu'elle contient et qui ne sont pas terminées. Archivez-la plutôt si vous n'en avez simplement plus besoin pour le moment. + + +## Étapes suivantes + +- [Assigner des tâches aux agents](/assigning-issues) — transformer une discussion en travail suivi. +- [Boîte de réception et abonnements](/inbox) — suivre l'activité de l'équipe qui vous concerne. +- [Matrice des fournisseurs](/providers) — capacités de session et de modèle de chaque outil de codage IA. diff --git a/apps/docs/content/docs/cli.fr.mdx b/apps/docs/content/docs/cli.fr.mdx new file mode 100644 index 00000000000..f8be01b3d02 --- /dev/null +++ b/apps/docs/content/docs/cli.fr.mdx @@ -0,0 +1,332 @@ +--- +title: Utiliser le CLI +description: Installez le CLI Multica, connectez-vous et gérez les espaces de travail, les tâches, les agents et les runtimes depuis le terminal. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Le CLI Multica sert à la fois à connecter des runtimes locaux et à gérer, depuis le terminal, les espaces de travail, les tâches, les agents et les automatisations. Cette page présente les parcours courants ; les options prises en charge par la version installée sont toujours définies par le `--help` de chaque commande. + +## Installation + +**macOS / Linux** + +```bash +curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash +``` + +Si Homebrew est installé, vous pouvez aussi exécuter : + +```bash +brew install multica-ai/tap/multica +``` + +**Windows PowerShell** + +```powershell +irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex +``` + +Vérifiez l'installation : + +```bash +multica version +``` + +## Première connexion + +Se connecter à Multica Cloud : + +```bash +multica setup +``` + +Se connecter à une instance auto-hébergée : + +```bash +multica setup self-host \ + --server-url https://api.example.com \ + --app-url https://app.example.com +``` + +`setup` enregistre l'adresse du serveur, ouvre un navigateur pour finaliser la connexion et démarre le daemon. Une fois l'opération terminée, vérifiez : + +```bash +multica auth status +multica daemon status +``` + +Si vous devez seulement vous reconnecter sans écraser le reste de la configuration, exécutez `multica login`. Sur une machine sans navigateur, créez d'abord un jeton d'accès personnel dans les paramètres web, puis saisissez-le avec : + +```bash +multica login --token +``` + +La commande vous invite à coller le jeton dans le terminal, ce qui évite que sa valeur complète n'apparaisse dans l'historique du shell. + +## Choisir un espace de travail + +Listez les espaces de travail et changez celui par défaut : + +```bash +multica workspace list +multica workspace switch +``` + +Les commandes suivantes utilisent cet espace de travail. Une commande isolée peut le remplacer avec `--workspace-id`, ou vous pouvez définir `MULTICA_WORKSPACE_ID`. + +Inviter des membres : + +```bash +multica workspace member invite teammate@example.com +multica workspace member invite admin@example.com --role admin +``` + +## Opérations courantes sur les tâches + +```bash +# Consulter et rechercher +multica issue list +multica issue get MUL-123 +multica issue search "échec de connexion" + +# Créer et mettre à jour +multica issue create --title "Corriger l'échec de connexion" +multica issue status MUL-123 in_progress +multica issue assign MUL-123 --to "Backend Agent" + +# Commentaires et exécutions +multica issue comment list MUL-123 +multica issue comment add MUL-123 --content "Vérifiez d'abord les tests de régression" +multica issue comment update --expected-revision 2 --content "Vérifiez les tests de régression et les notes de version" +multica issue runs MUL-123 +``` + +Lisez les descriptions ou commentaires longs depuis stdin pour ne pas avoir à gérer les retours à la ligne et les guillemets : + +```bash +multica issue create --title "Notes de mise à niveau" --description-stdin < notes.md +multica issue comment add MUL-123 --content-stdin < review.md +multica issue comment update --expected-revision 2 --content-file revised-review.md +``` + +Afficher les messages d'une exécution, ou l'arrêter : + +```bash +multica issue run-messages --issue MUL-123 +multica issue cancel-task --issue MUL-123 +``` + +## Agents et skills + +```bash +multica agent list +multica agent get +multica agent create --help +multica agent update --help + +multica skill list +multica skill get +multica skill import --url +multica agent skills add --skill-ids +``` + +Lorsqu'un import rencontre un skill du même nom, le comportement par défaut est de s'arrêter sans modifier le contenu existant. Choisissez selon votre intention : + +```bash +multica skill import --url --on-conflict overwrite +multica skill import --url --on-conflict rename +multica skill import --url --on-conflict skip +``` + +`overwrite` n'est autorisé que pour le créateur du skill et conserve l'ID d'origine ainsi que les liaisons avec les agents. + +## Daemon et runtimes + +```bash +multica daemon start +multica daemon stop +multica daemon restart +multica daemon status --output json +multica daemon logs --follow + +multica runtime list +multica runtime rename "Office Mac" +multica runtime usage +multica runtime activity +``` + +La suppression d'un runtime auquel des agents actifs sont encore liés est refusée par défaut. `multica runtime delete --cascade` dissocie ces agents, préserve leur configuration et leur historique, et annule leurs exécutions actives. + +Consultez [Daemon et runtimes](/daemon-runtimes) pour son fonctionnement et les profils personnalisés. + +## Vue d'ensemble des commandes + +| Commande | Rôle | +| --- | --- | +| `issue` | Créer, mettre à jour, assigner et rechercher des tâches ; gérer les commentaires, les abonnés, les étiquettes, les propriétés et les exécutions | +| `project` | Gérer les projets et leurs ressources | +| `label`, `property` | Gérer les étiquettes et les propriétés personnalisées de l'espace de travail | +| `agent`, `skill`, `squad` | Gérer les agents, les skills et les squads | +| `autopilot` | Gérer les automatisations, les déclencheurs et l'historique des exécutions | +| `workspace` | Créer, consulter et changer d'espace de travail, et inviter des membres | +| `repo` | Gérer les dépôts de l'espace de travail et les extraire localement | +| `daemon`, `runtime` | Démarrer et arrêter le daemon local ; consulter et gérer les runtimes | +| `attachment` | Envoyer ou télécharger des pièces jointes | +| `user profile` | Consulter ou mettre à jour le profil de l'utilisateur actuel | +| `auth`, `login`, `setup` | Se connecter, vérifier l'état de l'authentification et initialiser une connexion | +| `config` | Consulter ou modifier la configuration locale du profil CLI actuel | +| `update`, `version` | Mettre à jour le CLI ou afficher la version | + +`multica chat` lit **la session de messagerie externe qu'un agent traite actuellement** ; elle sert surtout aux agents des intégrations de messagerie et n'est pas une commande générale pour parcourir n'importe quelle discussion de l'espace de travail. + +## ID et formats de sortie + +Les tâches utilisent des clés comme `MUL-123` ou des UUID complets ; les préfixes courts d'UUID ne sont pas acceptés. + +Pour les autres ressources, les commandes `list` affichent généralement des ID courts copiables et prennent en charge `--full-id` pour obtenir l'UUID complet. Lorsqu'un ID court est ambigu, le CLI demande davantage de caractères ou l'UUID complet. Les ID courts d'exécution exigent aussi `--issue` pour identifier la tâche à laquelle ils appartiennent. + +De nombreuses commandes prennent en charge une sortie structurée : + +```bash +multica issue list --output json +multica agent list --output json +``` + +Les scripts doivent utiliser la sortie JSON plutôt que d'analyser les tableaux destinés au terminal. Les formats de sortie et les options de pagination pris en charge par chaque commande sont définis par `multica --help`. + +## Profils et configuration + +La configuration par défaut se trouve dans `~/.multica/config.json`. Utilisez `--profile ` pour isoler un ensemble distinct d'adresse de serveur, de jeton, d'espace de travail par défaut et d'état du daemon : + +```bash +multica setup self-host --profile staging \ + --server-url https://api.staging.example.com \ + --app-url https://app.staging.example.com + +multica issue list --profile staging +``` + +Les profils nommés se trouvent dans `~/.multica/profiles//config.json`. Inspectez les valeurs actuelles : + +```bash +multica config show +multica config show --profile staging +``` + +Ne définissez pas `MULTICA_DAEMON_PORT` dans le shell hôte, le service Compose ou le point d'entrée du conteneur qui démarre un daemon. Le daemon hôte déduit son port de contrôle de santé de `--profile` et injecte lui-même cette variable dans les exécutions d'agent. Supprimez complètement la variable des anciens environnements de démarrage : sinon, les versions 0.4.22 et 0.4.23 prennent ce shell pour une exécution gérée et refusent la connexion. Les versions plus récentes permettent à la connexion humaine protégée et à `daemon status` de se rétablir lorsque le port est le seul signal d'exécution, mais les commandes API ordinaires et celles qui résolvent un profil restent volontairement bloquées (fail-closed) tant que la variable n'est pas supprimée. + +Lorsque le CLI tourne au sein d'une exécution d'agent gérée par le daemon, il ne charge ni ne modifie ces fichiers de profil qui appartiennent à l'utilisateur humain. Les commandes de l'API Multica s'authentifient avec l'identifiant limité à l'exécution que le daemon injecte. `config show` et `config set` utilisent un état privé, local à l'exécution ; les commandes humaines ou locales comme `login`, `logout`, `setup`, `workspace switch`, les modifications de chemin des profils de runtime locaux, `daemon start` / `stop` / `restart`, `daemon logs` et `daemon probe-runtimes` sont indisponibles. Dans ce contexte, `auth status` n'affiche aucun élément de jeton. + +`daemon status` et `daemon disk-usage` restent disponibles pour qu'un agent puisse inspecter le runtime qui l'héberge, dans les limites de ce runtime : `status` interroge le port de contrôle de santé injecté par le daemon, et `disk-usage` analyse la racine des répertoires de travail injectée par le daemon. Aucune des deux n'accepte `--profile` ; `disk-usage` refuse aussi `--all-profiles` et `--workspaces-root`, et sa colonne STATUS reste vide, car la renseigner exigerait le jeton enregistré de l'utilisateur humain. Utilisez `du` et `df` pour tout ce qui sort de ce périmètre. + +Cela protège la résolution implicite des profils du CLI Multica tout en préservant les variables `HOME` et XDG habituelles qu'utilisent les outils des autres fournisseurs. Ce n'est pas une frontière du système de fichiers au niveau de l'OS : des processus qui tournent sous le même utilisateur système peuvent toujours ouvrir un chemin connu explicitement. Utilisez un utilisateur dédié, un conteneur, une VM ou une isolation équivalente lorsque cette garantie plus forte est nécessaire. + + +Les fichiers de configuration du CLI contiennent des jetons qui permettent d'accéder à Multica en votre nom. Ne les commitez pas dans un dépôt, ne les envoyez pas dans des journaux et ne les partagez avec personne. + + +## Référence des commandes + +Les tableaux ci-dessous couvrent toutes les commandes de premier niveau actuelles, regroupées comme le CLI lui-même les regroupe. Toutes les commandes acceptent les options globales `--server-url`, `--workspace-id`, `--profile` et `--debug`. `--output` est déclarée commande par commande, et le format par défaut varie : les commandes `list` affichent par défaut un tableau, tandis que les commandes `get` et `create` renvoient le plus souvent du JSON. Les options complètes sont définies par `multica --help`. + +### Core + +| Commande | Sous-commande | Rôle | Options principales | +| --- | --- | --- | --- | +| `issue` | `list` | Lister les tâches | `--status`, `--priority`, `--assignee`, `--project`, `--metadata` (répétable), `--property` (répétable, `"Name=Value"` ; `__none__` correspond à une valeur non définie), `--limit`, `--offset`, `--sort` (y compris `property:`), `--full-id`, `--resolve-properties` (JSON uniquement ; lignes avec les noms de propriété, d'option et de membre à côté des ID enregistrés) | +| | `get ` | Afficher une tâche | `--resolve-properties` (JSON uniquement ; lignes avec les noms de propriété, d'option et de membre à côté des ID enregistrés) | +| | `create` | Créer une tâche | `--title` (obligatoire), `--description` / `--description-stdin` / `--description-file`, `--status`, `--priority`, `--assignee`, `--parent`, `--stage`, `--project`, `--start-date`, `--due-date`, `--attachment` (répétable) | +| | `update ` | Mettre à jour les champs d'une tâche | Mêmes champs que `create`, plus `--position`, `--no-start` | +| | `assign ` | Assigner ou désassigner | `--to` (correspondance approximative sur le nom parmi les membres, les agents et les squads), `--to-id`, `--unassign`, `--no-start` | +| | `status ` | Changer le statut | `--no-start` | +| | `reorder ` | Déplacer au sein d'une colonne | | +| | `search ` | Rechercher des tâches | `--limit`, `--include-closed` | +| | `children ` | Lister les sous-tâches regroupées par étape | | +| | `pull-requests ` | Lister les PR associées | | +| | `comment list/add/delete/resolve/unresolve` | Gérer les commentaires | `add` : `--content` / `--content-stdin` / `--content-file`, `--parent`, `--attachment` ; `list` : `--since`, `--thread`, `--tail` | +| | `subscriber list/add/remove ` | Gérer les abonnés | `--user`, `--user-id` (par défaut, l'utilisateur actuel) | +| | `label list/add/remove ` | Gérer les étiquettes d'une tâche | | +| | `metadata list/get/set/delete ` | Gérer les métadonnées clé-valeur au niveau de la tâche | | +| | `property list/set/unset ` | Définir les valeurs des propriétés personnalisées | `set` : `--name`, `--value` (`actor` / `multi_actor` acceptent un nom de membre, un e-mail ou un ID) | +| | `runs ` | Afficher l'historique des exécutions | `--full-id` | +| | `run-messages ` | Afficher les messages d'une exécution | `--since`, `--issue` | +| | `usage ` | Afficher la consommation de tokens agrégée | | +| | `rerun ` | Remettre en file une exécution pour l'assigné actuel | | +| | `cancel-task ` | Annuler une exécution en cours ou en file d'attente | `--issue` | +| `project` | `list/get/create/update/delete` | Gérer les projets | | +| | `status ` | Changer le statut d'un projet | | +| | `resource list/add/update/remove` | Gérer les ressources d'un projet | `--type`, `--url`, `--local-path`, `--daemon-id`, `--execution-mode` (`in_place` / `worktree` pour un répertoire local) | +| `label` | `list/get/create/update/delete` | Gérer les étiquettes de l'espace de travail | `list` : `--resource-type` (`issue` ou `skill`), `--full-id` ; `create` : `--name`, `--color`, `--resource-type` (`issue` ou `skill`), `--description` | +| `property` | `list/get/create/update/archive/unarchive` | Gérer les propriétés personnalisées de l'espace de travail | `create` : `--name`, `--type` (`text`, `number`, `select`, `multi_select`, `date`, `checkbox`, `url`, `actor`, `multi_actor`), `--option` (répétable, types select uniquement) ; `list` : `--include-archived` ; le type ne peut plus être modifié après la création | +| `agent` | `list/get/create/update/archive/restore` | Gérer les agents | `--name`, `--runtime-id` (obligatoire pour `create`), `--instructions`, `--conversation-starters`, `--model`, `--thinking-level`, `--mcp-config`, `--permission-mode`, `--max-concurrent-tasks` | +| | `copy ` | Copier dans un nouvel agent ; l'original reste intact | `--name` (par défaut, le nom d'origine suivi de ` (copy)`), `--runtime-id` (copier vers un autre runtime exige aussi `--model`), `--no-skills` ; la configuration secrète comme `custom_env`, `mcp_config` et `runtime_config` n'est pas copiée — fournissez-la à nouveau avec les mêmes options que pour `create` | +| | `tasks ` | Afficher les exécutions d'un agent | | +| | `avatar ` | Envoyer un avatar | | +| | `env get/set ` | Lire et écrire les variables d'environnement personnalisées (propriétaires et administrateurs uniquement) | | +| | `skills list/set/add ` | Gérer les skills rattachés | `--skill-ids` (`set` remplace toute la liste, `add` ajoute à la suite) | +| | `mcp list/add/enable/disable/remove ` | Assigner des serveurs MCP de l'espace de travail à cet agent | Récupérez l'ID du serveur avec `workspace mcp list`. Une entrée de la bibliothèque n'a aucun effet tant qu'elle n'est pas ajoutée ici ; `disable` cesse de l'envoyer sans supprimer l'assignation | +| `autopilot` | `list/get/create/update/delete` | Gérer les automatisations | `create` : `--title`, `--agent`, `--mode` (tous obligatoires), `--project`, `--subscriber` (répétable) | +| | `trigger ` | Déclencher une exécution manuellement | | +| | `runs ` | Afficher l'historique des exécutions | | +| | `trigger-add/trigger-update/trigger-delete/trigger-rotate-url` | Gérer les déclencheurs de planification et webhook | | +| `workspace` | `list/get/create/update/switch` | Gérer les espaces de travail ; `switch` définit l'espace de travail par défaut du profil actuel | | +| | `mcp list/add/update/remove` | Gérer la bibliothèque de serveurs MCP de l'espace de travail | Les écritures sont réservées aux propriétaires et administrateurs. Un serveur ajouté ici n'est assigné à aucun agent — attribuez-le à un agent avec `multica agent mcp add`. `list` n'affiche que les noms et les transports ; l'entrée enregistrée est en écriture seule et n'est jamais renvoyée. `--server-config-file` / `--server-config-stdin` gardent les jetons hors de l'historique du shell | +| | `member list/invite ` | Consulter les membres, envoyer des invitations | `invite` : `--role` (member ou admin, member par défaut) | +| `repo` | `list/add/remove/checkout` | Gérer les dépôts de l'espace de travail et les extraire localement | `--url` (répétable) ; `checkout` : `--ref` | +| `skill` | `list/get/create/update/delete` | Gérer les skills | | +| | `import` | Importer un skill depuis une URL ou un fichier local | `--url` / `--file`, `--on-conflict` (`fail`, `overwrite`, `rename`, `skip` ; `fail` par défaut) | +| | `search ` | Rechercher des skills | | +| | `files list/upsert/delete ` | Gérer les fichiers d'un skill | | +| | `label list/add/remove ` | Gérer les étiquettes d'un skill | `add` / `remove` : `` accepte un UUID complet ou un préfixe court | +| `squad` | `list/get/create/update/delete` | Gérer les squads (`delete` archive) | | +| | `member list/add/set-role/remove` | Gérer les membres d'un squad | | +| | `activity ` | Enregistrer une évaluation du chef | | +| `chat` | `history`, `thread [id]` | Lire la session de messagerie externe qu'un agent traite actuellement | `--limit`, `--before` | + +### Runtime + +| Commande | Sous-commande | Rôle | Options principales | +| --- | --- | --- | --- | +| `daemon` | `start` | Démarrer le daemon local | `--foreground`, `--device-name`, `--runtime-name`, `--workspaces-root`, `--poll-interval`, `--heartbeat-interval`, `--agent-timeout` (`0` signifie aucune limite), `--max-concurrent-tasks`, `--no-auto-update` ; chacune a une variable d'environnement `MULTICA_*` correspondante | +| | `stop` / `status` / `restart` | Arrêter, vérifier l'état, redémarrer (`restart` accepte les mêmes options que `start`) | | +| | `logs` | Afficher les journaux du daemon | `--follow`, `--lines` | +| | `disk-usage` | Afficher l'utilisation du disque local | `--by-workspace`, `--by-task`, `--top` | +| `runtime` | `list/usage/activity/update/rename/delete` | Consulter et gérer les runtimes | `delete` : `--cascade` (dissocie les agents liés, préserve leurs données et annule leurs exécutions) | +| | `profile list/create/update/delete` | Gérer les profils de runtime personnalisés | | +| | `profile set-path/unset-path ` | Fixer le chemin d'un exécutable local (local uniquement, jamais envoyé au serveur) | | + +### Additional + +| Commande | Sous-commande | Rôle | Options principales | +| --- | --- | --- | --- | +| `auth` | `status` / `logout` | Vérifier l'état de l'authentification ; `logout` supprime seulement le jeton enregistré localement et ne le révoque pas côté serveur | | +| `user` | `profile get/update` | Consulter ou mettre à jour le profil de l'utilisateur actuel | | +| `login` | — | Se connecter via le navigateur et configurer automatiquement tous les espaces de travail | `--token` (demande la saisie de façon interactive dans le terminal lorsque l'option est passée sans valeur) | +| `setup` | `cloud` (par défaut), `self-host` | Enregistrer l'adresse du serveur, finaliser la connexion et démarrer le daemon | `self-host` : `--server-url`, `--app-url`, `--port`, `--frontend-port` | +| `attachment` | `download `, `upload ` | Télécharger ou envoyer des pièces jointes | `download` : `--output-dir` ; `upload` : `--task` | +| `config` | `show`, `set ` | Consulter ou modifier la configuration locale du profil actuel | Priorité : options de ligne de commande > variables d'environnement > `config.json` > valeurs par défaut intégrées ; définissez une chaîne vide pour effacer une valeur | +| `update` | — | Mettre à jour le CLI vers la dernière version | | +| `version` | — | Afficher les informations de version | `--output` (`text` ou `json`) | + +## Piloter Multica depuis un autre agent de codage + +Si l'essentiel de votre travail se passe déjà dans Codex, Claude Code ou Cursor, vous +pouvez piloter Multica depuis ces outils au lieu de basculer vers un terminal. Le +[skill Multica CLI](https://github.com/multica-ai/multica-cli) apprend à ces agents à +utiliser les commandes de cette page en toute sécurité : lire les tâches et les fils de +commentaires sans gaspiller de tokens, écrire les commentaires via un fichier, et gérer +les effets de bord qu'entraînent les mentions, les changements de statut et les assignations. + +Il passe entièrement par votre CLI authentifié et n'accorde aucun accès qui lui soit propre : +les permissions proviennent toujours de votre connexion, du profil sélectionné et de +l'espace de travail. Il nécessite le CLI v0.4.26 ou ultérieur. Le README du dépôt explique +l'installation via la marketplace de plugins de Claude Code, l'installateur de skills de +Codex, Cursor et tout autre outil qui charge des instructions Markdown. + +## Étapes suivantes + +- [Skill Multica CLI](https://github.com/multica-ai/multica-cli) — piloter Multica depuis Codex, Claude Code ou Cursor. +- [Authentification et jetons](/auth-tokens) — créer, renouveler et révoquer des PAT. +- [Dépannage](/troubleshooting) — diagnostiquer les erreurs de commande et les exécutions qui ne démarrent jamais. +- [Créer et configurer un agent](/agents-create) — la sémantique complète de chaque champ de `agent create`. diff --git a/apps/docs/content/docs/cloud-quickstart.fr.mdx b/apps/docs/content/docs/cloud-quickstart.fr.mdx new file mode 100644 index 00000000000..9f398fb3652 --- /dev/null +++ b/apps/docs/content/docs/cloud-quickstart.fr.mdx @@ -0,0 +1,95 @@ +--- +title: Démarrage rapide +description: Connectez un ordinateur, créez votre premier agent et faites-lui accomplir sa première tâche. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + + +Avant qu'un agent puisse s'exécuter, l'ordinateur qui l'exécute doit disposer d'au moins un [outil de codage IA pris en charge](/providers), installé et connecté — par exemple Claude Code, Codex ou Cursor. Multica ne fournit pas ces outils. + + +## 1. Connectez-vous et ouvrez votre espace de travail + +Connectez-vous sur le web ([multica.ai](https://multica.ai)) ou avec [Multica Desktop](https://multica.ai/download). La connexion par code de vérification envoyé par e-mail et la connexion Google sont toutes deux prises en charge. + +- **Desktop** : peut connecter automatiquement cet ordinateur. +- **Web** : connectez l'ordinateur depuis la ligne de commande à l'étape suivante. + +## 2. Connectez un ordinateur + +Les agents exécutent le travail sur les ordinateurs que vous connectez. Ouvrez **Équipe IA → Runtimes** en bas de la barre latérale : + +- **Avec Desktop** : Desktop enregistre automatiquement cet ordinateur comme runtime et détecte les outils de codage IA installés. Pour un outil que vous venez d'installer, cliquez sur **Actualiser** pour relancer la détection. +- **Avec le Web, ou pour ajouter un autre ordinateur** : cliquez sur **Ajouter un ordinateur** en haut à droite, puis exécutez les deux commandes de la boîte de dialogue dans un terminal de l'ordinateur cible : + + **macOS / Linux** + + ```bash + curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash + multica setup + ``` + + **Windows PowerShell** + + ```powershell + irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex + multica setup + ``` + + `multica setup` ouvre un navigateur pour finaliser la connexion, puis laisse le daemon tourner en arrière-plan. Une fois le daemon en ligne, la boîte de dialogue détecte généralement l'ordinateur en moins d'une minute. + +**Vérification** : un ordinateur en ligne apparaît dans la liste des runtimes. + +![Boîte de dialogue Ajouter un ordinateur : les deux commandes et l'avis d'attente de détection](/images/docs/quickstart-add-computer.webp) + + +Les commandes de connexion diffèrent légèrement sur les instances auto-hébergées — consultez le [démarrage rapide en auto-hébergement](/self-host-quickstart). + + +## 3. Créez un agent + +Ouvrez **Équipe IA → Agents** dans la barre latérale, cliquez sur **Nouvel agent** et choisissez une méthode : + +- **Partir de zéro** : remplissez vous-même les champs. Seul le nom est strictement obligatoire — confirmez le runtime et l'outil de codage IA, puis créez l'agent. +- **Construire avec l'IA** : décrivez ce que vous voulez ; l'Agent Builder vous pose des questions et génère la configuration. Cette méthode nécessite un runtime en ligne. + +Tout le reste peut être affiné plus tard — voir [Créer et configurer des agents](/agents-create). + +**Vérification** : votre agent apparaît dans la liste des agents et s'affiche comme en ligne. + +![Choix du point de départ pour créer un agent : Partir de zéro et Construire avec l'IA](/images/docs/quickstart-new-agent.webp) + +## 4. Confiez-lui votre première tâche + +Cliquez sur **Nouvelle tâche** en haut de la barre latérale (ou appuyez sur `C`). Dans le mode agent par défaut : + +1. Sous **Créé par**, sélectionnez l'agent que vous venez de créer. +2. Décrivez le travail en une ou deux phrases, par exemple « Explique comment cet espace de travail peut être utilisé. » +3. Validez. Multica crée la tâche, l'assigne à l'agent et lance l'exécution immédiatement. + +![Boîte de dialogue Nouvelle tâche en mode agent : Créé par défini sur Multica Assistant](/images/docs/quickstart-new-issue.webp) + +## 5. Suivez l'avancement et les résultats + +Après validation, ouvrez la tâche : le journal d'exécution affiche le statut de l'exécution, et l'avancement ainsi que les réponses de l'agent apparaissent dans le fil d'activité. + +**Vérification** : le statut du journal d'exécution passe à Terminée et la réponse de l'agent apparaît dans le fil d'activité. Pour voir la transcription complète, cliquez sur **Voir la transcription**. + +![La réponse de l'agent dans la tâche et un journal d'exécution terminé](/images/docs/quickstart-run-result.webp) + +## Problèmes courants + +| Symptôme | À vérifier en premier | +|---|---| +| Aucun runtime trouvé | Vérifiez que l'outil de codage IA est installé et fonctionne dans un terminal. Sur Desktop, cliquez sur **Actualiser** ; avec le CLI, relancez `multica setup`. | +| Le runtime apparaît hors ligne | Gardez Desktop ouvert ; avec le CLI, exécutez `multica daemon status`, puis `multica daemon start` s'il n'est pas lancé. | +| La tâche reste en file d'attente | Vérifiez que le runtime utilisé par l'agent est en ligne. Lorsqu'un runtime atteint sa limite de parallélisme, les nouvelles exécutions restent en file d'attente. | + +Pour d'autres cas, consultez [Dépannage](/troubleshooting). + +## Étapes suivantes + +- [Concepts clés](/concepts) — découvrez en trois minutes tous les objets clés de Multica. +- [Fonctionnement de Multica](/how-multica-works) — comment les tâches, les agents, les runtimes et chaque exécution s'articulent. +- [Mettre les agents au travail](/triggering-agents) — au-delà de l'assignation : @mentions, Discussion et automatisations. diff --git a/apps/docs/content/docs/comments.fr.mdx b/apps/docs/content/docs/comments.fr.mdx new file mode 100644 index 00000000000..61a1aaa931e --- /dev/null +++ b/apps/docs/content/docs/comments.fr.mdx @@ -0,0 +1,70 @@ +--- +title: Commentaires +description: Ajoutez des informations, répondez aux discussions et @mentionnez des membres ou des agents sur une tâche, en conservant un historique complet de la collaboration. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Les commentaires sont le lieu de la collaboration continue sur une [tâche](/issues). Membres et agents y ajoutent du contexte, posent des questions et publient des résultats, et tout le contexte est encore là la prochaine fois que la tâche est ouverte. + +## Publier des commentaires et des réponses + +Saisissez votre texte dans la zone Activité de la tâche et envoyez-le pour créer un commentaire. Les commentaires prennent en charge la mise en forme, les blocs de code, les liens et les pièces jointes. + +Répondre à un commentaire crée un fil de discussion. Les réponses peuvent s'imbriquer davantage, mais elles restent toutes sous la même discussion de premier niveau. + +## Annoter des commentaires et des descriptions + +Sur le web et dans l'application de bureau, sélectionnez du texte dans n'importe quel commentaire publié, y compris les vôtres, puis choisissez **Ajouter à la réponse** dans la barre d'outils de sélection. Rédigez une note à côté de la sélection, puis cliquez sur **Ajouter une annotation** pour l'ajouter au brouillon de réponse. Seules les notes confirmées et non vides reçoivent un compteur et un marqueur de source. Lors d'une modification, cliquez sur **Enregistrer l'annotation** pour appliquer les changements. Entrée confirme ; Maj + Entrée insère un saut de ligne. Échap ou un clic à l'extérieur annule les modifications non confirmées. Cliquez sur le marqueur de source numéroté pour la modifier à nouveau. Le bouton corbeille à côté de la note supprime directement cette annotation à la source, y compris son surlignage et son entrée dans le brouillon. Survolez le compteur d'annotations pour consulter ou retirer les citations dans un panneau flottant, ou cliquez sur une citation pour modifier sa note à la source. Vous pouvez aussi ouvrir le panneau d'un clic ou au clavier. Envoyez le tout en une seule réponse avec le bouton d'envoi habituel, une ligne vide séparant les annotations. Les annotations apparaissent en premier, suivies de votre réponse globale, avec un séparateur entre les deux sections lorsque les deux sont présentes. Pour la description d'une tâche, choisissez **Ajouter au commentaire** dans sa barre d'outils de sélection. Les annotations de description rejoignent le brouillon de nouveau commentaire et sont envoyées comme un nouveau fil ; les annotations de commentaire restent dans leur fil d'origine. + +Pour les annotations de commentaire, la première annotation enregistrée fixe la cible de la réponse, y compris pour les réponses imbriquées. Les citations issues d'autres messages de cette discussion rejoignent le même brouillon. Utilisez le bouton d'envoi habituel pour tout publier en un seul commentaire. Les mentions citées ne notifient ni ne déclenchent personne ; les mentions que vous rédigez vous-même suivent les règles habituelles. + +Les brouillons sont propres à cet appareil et à cet espace de travail, survivent aux rechargements et expirent au bout de 30 jours. Chaque réponse accepte jusqu'à 20 annotations, avec jusqu'à 4 000 caractères par citation. Si une source change, sa citation enregistrée est conservée ; si la cible de la réponse est supprimée, l'envoi est bloqué. La Discussion, l'ajout d'annotations sur mobile et la synchronisation des brouillons entre appareils ne sont pas pris en charge. + +## Résoudre un fil de discussion + +Lorsqu'une question aboutit à une conclusion, marquez l'ensemble du fil comme résolu, ou désignez une réponse comme conclusion ; un fil ne conserve qu'une seule conclusion à la fois. Les fils résolus sont repliés mais peuvent toujours être dépliés. + +Répondre dans un fil résolu le rouvre automatiquement. + +## @mentions + +Tapez `@` et choisissez une cible dans la liste : + +| Cible | Effet | +|---|---| +| **Membre** | Reçoit une notification de mention dans la boîte de réception ; une @mention dans un commentaire ne crée pas d'abonnement. | +| **Agent** | Crée une exécution pour cet agent ; les agents n'ont pas de boîte de réception. | +| **Squad** | Notifie les membres du squad et déclenche le chef du squad. | +| **Tâche** | Insère un lien vers une autre tâche — aucune notification, aucun déclenchement d'agent. | +| **@all** | Notifie tous les membres de l'espace de travail et ne déclenche aucun agent. | + +Taper `@name` en texte brut ne crée pas de mention ; choisissez dans la liste de suggestions de l'éditeur. Taper un identifiant de tâche valide le convertit automatiquement en lien vers la tâche. + + +@mentionner un agent dans un commentaire ne change ni l'assigné ni le statut de la tâche. Pour l'aperçu des déclenchements, le routage automatique des réponses simples et la fusion des commentaires consécutifs, voir [@mentionner des agents dans les commentaires](/mentioning-agents). + + +## Abonnements et boîte de réception + +Les membres qui commentent sont automatiquement abonnés à la tâche, et l'activité ultérieure ainsi que les @mentions arrivent dans la [boîte de réception](/inbox) ; vous pouvez aussi vous abonner ou vous désabonner manuellement en haut à droite de la tâche. Voir [Boîte de réception et abonnements](/inbox) pour la liste complète des sources d'abonnement et les règles de notification. + +## Réactions + +Ajoutez une réaction emoji à un commentaire pour en accuser réception, marquer votre accord ou donner un retour rapide. Les réactions ne déclenchent pas d'agents et ne changent pas le statut de la tâche. + +## Modifier et supprimer + +Les auteurs de commentaires peuvent modifier ou supprimer leurs propres commentaires ; les `owner` et `admin` de l'espace de travail peuvent aussi modifier et supprimer les commentaires de n'importe qui. Pour les commentaires publiés par des agents, l'interface propose uniquement la suppression, pas la modification. + +Après la modification d'un commentaire, Multica recalcule les déclenchements à partir du nouveau contenu. Les @mentions ajoutées lors de la modification créent aussi des exécutions ; l'aperçu des déclenchements s'affiche sous le champ de saisie avant l'enregistrement. + + +La suppression d'un commentaire est irréversible. Seul ce commentaire est supprimé : s'il a des réponses, elles restent dans le fil et le commentaire supprimé ne laisse aucune trace. Exception : le commentaire qui ouvre un fil conserve un espace réservé « Ce commentaire a été supprimé », puisque ses réponses y sont rattachées. Si le contenu est erroné, modifiez-le plutôt. + + +## Étapes suivantes + +- [@mentionner des agents dans les commentaires](/mentioning-agents) — les différences entre @mentions, réponses et assignation. +- [Boîte de réception et abonnements](/inbox) — gérez l'activité que vous devez suivre. +- [Assigner des tâches aux agents](/assigning-issues) — faites d'un agent l'assigné de la tâche. diff --git a/apps/docs/content/docs/community-maintained.fr.mdx b/apps/docs/content/docs/community-maintained.fr.mdx new file mode 100644 index 00000000000..5d9a6349f50 --- /dev/null +++ b/apps/docs/content/docs/community-maintained.fr.mdx @@ -0,0 +1,40 @@ +--- +title: Domaines maintenus par la communauté +description: Quelles parties de Multica sont maintenues par des contributeurs de la communauté, ce que cela implique pour le support, et comment signaler un problème dans l'un de ces domaines. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +La majeure partie de Multica est développée et prise en charge par l'équipe principale. Quelques domaines ont été apportés par des bénévoles qui en sont restés les mainteneurs. Ces domaines sont livrés dans chaque version, mais ne bénéficient d'aucun SLA de support officiel. + +Cette page en est la liste de référence. Elle existe pour que vous sachiez sur quoi vous vous appuyez avant d'y bâtir un flux de travail, et pour que les personnes qui ont contribué à ce travail soient créditées nommément. + +## Ce que signifie « maintenu par la communauté » + +**Ce à quoi nous nous engageons.** Le domaine est livré dans chaque version. L'équipe principale veille à ce qu'il continue de compiler et que ses tests restent au vert lors des refactorisations de la couche partagée, et transmet à ses mainteneurs les issues qui concernent ce domaine. + +**Ce que nous ne pouvons pas promettre.** Nous n'utilisons pas ces plateformes au quotidien : nous ne pouvons donc pas vérifier leur comportement en conditions réelles. Un bug qui nécessite un accès réel à la plateforme dépend de la disponibilité d'un bénévole, ce qui exclut toute garantie de délai de réponse. + +**Comment un domaine est retiré.** Si un domaine cesse de fonctionner d'une manière que nous ne pouvons pas corriger sans accès réel, et qu'aucun correctif n'apparaît au bout de quelques versions, nous pouvons le déprécier plutôt que de le laisser discrètement cassé. Cette règle protège les mainteneurs autant que vous : un bénévole qui manque de temps ou passe à autre chose ne doit de correctif à personne. + +## Les domaines + +| Domaine | Code | Mainteneurs | Depuis | Statut | +| --- | --- | --- | --- | --- | +| [Intégration de messagerie DingTalk](/dingtalk-bot-integration) | `server/internal/integrations/dingtalk` | [@yyclaw](https://github.com/yyclaw) | 2026-08 | Actif | +| [Intégration de messagerie WeCom](/channels) | `server/internal/integrations/wecom` | [@leroy-chen](https://github.com/leroy-chen), [@seacen](https://github.com/seacen) | 2026-08 | Actif | +| [Intégration de messagerie Telegram](/telegram-bot-integration) | `server/internal/integrations/telegram` | [@leonzone](https://github.com/leonzone) | 2026-08 | Actif | + +Cette liste ne change que lorsque la responsabilité d'un domaine change. Tout ce qui n'y figure pas est maintenu par l'équipe principale. + +## Signaler un problème dans l'un de ces domaines + + +Ouvrez une [issue GitHub](https://github.com/multica-ai/multica/issues). Merci de ne pas contacter un mainteneur en message privé ni de le @-mentionner directement — les mainteneurs ont accepté d'être le premier interlocuteur pour les questions que **nous** leur transmettons, pas un service d'assistance que n'importe qui peut solliciter. + + +Le formulaire d'issue comporte un champ **Area**. C'est en le remplissant que vous nous permettez de transmettre le signalement au bon mainteneur : cela vaut bien un clic de plus. + +## Devenir mainteneur + +Si vous apportez une nouvelle intégration ou un autre domaine conséquent, nous aurons cette conversation avec vous avant la fusion : ce à quoi nous nous engageons, ce que nous vous demanderions, et la règle de retrait ci-dessus. Vous pouvez tout à fait refuser — cela change la façon dont le domaine est étiqueté, pas le fait que le travail soit fusionné. diff --git a/apps/docs/content/docs/concepts.fr.mdx b/apps/docs/content/docs/concepts.fr.mdx new file mode 100644 index 00000000000..0bce6b57845 --- /dev/null +++ b/apps/docs/content/docs/concepts.fr.mdx @@ -0,0 +1,70 @@ +--- +title: Concepts clés +description: Tous les objets clés de Multica et leurs relations, sur une seule page. +--- + +![Schéma des objets clés de Multica : dans un espace de travail, une tâche déclenche un agent par assignation ou par mention, l'agent crée une exécution, l'exécution s'effectue sur un runtime et les résultats sont réécrits dans la tâche ; la Discussion et l'automatisation sont deux autres sources de déclenchement, les skills et les squads se rattachent aux agents, les projets organisent les tâches, et les notifications arrivent dans la Boîte de réception](/images/docs/concepts-core-objects.webp) + +## Objets de base + +### Espace de travail + +Le périmètre autonome dans lequel une équipe travaille ensemble ; tout le travail et toute la configuration s'y trouvent. Humains et agents collaborent dans le même espace de travail. Voir [Espaces de travail](/workspaces). + +### Tâche + +Un travail à réaliser, accompagné de la description, de la discussion, du statut et de l'historique qui s'accumulent autour de lui. Les tâches sont l'unité de base du travail quotidien dans Multica ; l'assigné peut être un membre, un agent ou un squad. Voir [Tâches](/issues). + +### Projet + +Regroupe des tâches liées autour d'un même objectif, suit l'avancement global et peut rattacher des ressources, comme des dépôts et des répertoires, utilisées pendant l'exécution. Voir [Projets](/projects). + +## Agents et exécution + +### Agent + +Un collaborateur IA de l'espace de travail — une configuration réutilisable : nom, instructions, modèle, skills, Accès et runtime. Un agent n'est pas un processus qui tourne en permanence ; il ne s'exécute que lorsqu'il est déclenché. Voir [Agents](/agents). + +### Skill + +Un paquet de capacités réutilisable. Les instructions définissent qui est un agent ; un skill décrit comment réaliser un type de travail et peut être rattaché à plusieurs agents. Voir [Skills](/skills). + +### Runtime + +L'endroit où l'exécution a réellement lieu : un ordinateur connecté à Multica et les outils de codage IA qui y sont installés. L'agent est l'identité ; le runtime est l'ordinateur qui l'exécute. Voir [Daemon et runtimes](/daemon-runtimes). + +### Exécution + +L'enregistrement concret d'une exécution d'un agent. Chaque déclenchement produit une exécution, qu'un runtime mène à bien avant de réécrire les résultats. Une tâche peut produire plusieurs exécutions au fil du temps ; une exécution terminée ne signifie pas que la tâche est terminée. Voir [Exécutions](/tasks). + +## Collaboration et automatisation + +### Squad + +Un groupe d'agents et de membres dirigé par un agent chef. Assignez une tâche à un squad et le chef coordonne le travail. Voir [Squads](/squads). + +### Discussion + +Un moyen d'échanger qui n'est rattaché à aucune tâche — idéal pour les questions et les essais rapides. Chaque message déclenche une exécution. Voir [Discuter avec les agents](/chat). + +### Boîte de réception + +Le centre de notifications d'un membre : l'activité sur les tâches auxquelles il est abonné, les mentions et les assignations y arrivent. Les agents n'utilisent pas la Boîte de réception. Voir [Boîte de réception et abonnements](/inbox). + +### Automatisation + +Déclenche automatiquement des exécutions d'agents selon une planification ou à partir d'événements externes ; vous pouvez aussi en lancer une manuellement. Voir [Automatisations](/autopilots). + +## Relations entre les objets + +- L'**espace de travail** contient tout ; humains et **agents** y collaborent. +- Une **tâche** consigne un travail ; les tâches liées sont organisées en **projets**. +- Une assignation, une @mention, une **discussion** ou une **automatisation** déclenche un agent et produit une **exécution**. +- L'exécution s'effectue sur un **runtime**, et les résultats sont réécrits là où elle a été déclenchée. +- Les **skills** permettent de réutiliser ce qui fonctionne d'un agent à l'autre ; les **squads** permettent à plusieurs agents de travailler ensemble. +- Chaque notification destinée aux humains en cours de route arrive dans la **Boîte de réception**. + +## Étapes suivantes + +- [Mettre les agents au travail](/triggering-agents) — le point d'entrée des quatre méthodes de déclenchement. +- [Fonctionnement de Multica](/how-multica-works) — le parcours complet d'une exécution. diff --git a/apps/docs/content/docs/daemon-runtimes.fr.mdx b/apps/docs/content/docs/daemon-runtimes.fr.mdx new file mode 100644 index 00000000000..b62b9bc3581 --- /dev/null +++ b/apps/docs/content/docs/daemon-runtimes.fr.mdx @@ -0,0 +1,174 @@ +--- +title: Daemon et runtimes +description: Comment Multica connecte les ordinateurs, détecte les outils de codage IA et lance les exécutions. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Multica enregistre et coordonne le travail ; ce sont les ordinateurs connectés qui l'exécutent. Le **daemon** d'un ordinateur prend en charge les exécutions et invoque les outils de codage IA installés sur cette machine. + +## Daemon et runtime + +- Le **daemon** est le processus d'arrière-plan de Multica qui tourne sur un ordinateur. Il se connecte au serveur, détecte les outils locaux, prend en charge les exécutions et renvoie les résultats. +- Un **runtime** représente un environnement d'exécution concret mis à la disposition d'un espace de travail. Il correspond à un ordinateur associé à un outil de codage IA — ou à un profil de runtime personnalisé — sur cet ordinateur. + +Par exemple, un ordinateur sur lequel sont installés Claude Code et Codex est connecté à deux espaces de travail. Le daemon enregistre un runtime Claude Code et un runtime Codex pour chaque espace de travail. Redémarrer le daemon met à jour les enregistrements existants ; il ne crée pas de nouveaux runtimes à chaque fois pour la même combinaison. + +## Lieu d'exécution et périmètre des données + +Les outils de codage IA qu'invoque un runtime local, leurs propres identifiants de connexion et vos répertoires de code locaux restent tous sur l'ordinateur connecté. Le serveur Multica n'exécute pas de commandes à la place des outils locaux et ne téléverse pas automatiquement l'intégralité de votre répertoire de travail. + +Pour permettre à l'équipe de collaborer, le serveur stocke les tâches, les commentaires, la configuration des agents, le contexte des exécutions, les enregistrements d'exécution et les résultats que les agents renvoient. Ce contenu peut inclure des extraits de code ou d'autres éléments de contexte du projet qu'un agent a choisi de lire et d'inclure dans ses réponses. + + +Les variables d'environnement personnalisées d'un agent sont stockées côté serveur et envoyées au runtime au moment de l'exécution. Ne comprenez pas « exécution locale » comme « chaque secret n'existe que sur cette machine » : les variables d'environnement personnalisées et la configuration MCP résident sur le serveur, et leur affichage est limité par les règles applicables aux valeurs sensibles. + + +## Démarrer le daemon + +Avec Multica Desktop, l'application démarre le daemon automatiquement — aucune commande supplémentaire n'est nécessaire. + +Sur le web, sur un ordinateur distant ou dans un environnement sans interface graphique, [installez d'abord le CLI Multica](/cli), puis exécutez : + +```bash +multica daemon start +``` + +Par défaut, le daemon s'exécute en arrière-plan. Commandes courantes : + +| Commande | Rôle | +| --- | --- | +| `multica daemon status` | Afficher l'état du daemon et de la connexion | +| `multica daemon logs -f` | Suivre les journaux | +| `multica daemon restart` | Redémarrer le daemon et détecter à nouveau les outils locaux | +| `multica daemon stop` | Arrêter le daemon | +| `multica daemon start --foreground` | Exécuter dans le terminal courant, pour le débogage | + +Pour placer les répertoires de travail des exécutions sur un autre disque, enregistrez une racine pour le profil courant avec `multica config set workspaces_root `, ou passez `--workspaces-root ` à `daemon start` ou `daemon restart`. L'option prime sur `MULTICA_WORKSPACES_ROOT`, qui prime elle-même sur la configuration du profil. Les répertoires d'exécution existants ne sont pas déplacés lorsque la racine change. + +Au démarrage, le daemon détecte les outils de codage IA pris en charge présents dans le `PATH` et enregistre des runtimes pour les espaces de travail auxquels vous êtes autorisé à vous connecter. Si vous venez d'installer un outil ou de vous y connecter, redémarrez le daemon pour qu'il le détecte à nouveau. + +Pour démarrer, le daemon a besoin de détecter au moins un outil de codage IA intégré pris en charge. Les méthodes d'installation et les noms des exécutables figurent dans [Installer les outils de codage IA](/install-agent-runtime). + +## Distribution et statut en ligne + +Une fois enregistré, un runtime maintient une connexion persistante. Lorsqu'une nouvelle exécution entre dans la file d'attente, le serveur prévient le daemon concerné ; le daemon interroge aussi le serveur périodiquement, en filet de sécurité après une interruption de connexion. Ainsi, lorsqu'un runtime est en ligne et dispose de capacité libre, les exécutions démarrent généralement immédiatement. + +Le daemon envoie un signal de présence (heartbeat) toutes les 15 secondes. Le serveur combine ces signaux et l'état de la connexion pour déterminer si un runtime est en ligne ; lorsqu'un daemon s'arrête de manière inattendue, le runtime apparaît généralement hors ligne en 3 minutes environ au plus tard. + +![Détails du runtime pour un ordinateur en ligne : le même daemon a enregistré 7 runtimes, une ligne par outil de codage IA détecté, chacune indiquant le statut en ligne et la version du CLI](/images/docs/runtime-machine-detail.webp) + +Lorsqu'un runtime est hors ligne : + +- Les exécutions déjà en file d'attente attendent que le runtime revienne. Elles n'échouent qu'une fois que le runtime a cessé d'envoyer des signaux de présence pendant plus longtemps que le délai de grâce de reconnexion **et** que l'exécution elle-même est en file d'attente depuis aussi longtemps ; ainsi, un runtime simplement occupé conserve sa file, et le travail assigné à un runtime déjà hors ligne bénéficie tout de même d'un délai de grâce complet. +- Les exécutions en cours échouent ; les exécutions de tâche ou de discussion éligibles peuvent être relancées automatiquement. +- Au redémarrage, le daemon réenregistre ses runtimes et récupère les exécutions qui ne se sont pas terminées proprement la dernière fois. +- Un runtime hors ligne depuis plus de 7 jours et auquel aucun agent n'est lié (y compris les agents archivés) est supprimé automatiquement. + +Les états détaillés et les règles de relance figurent dans [Exécutions](/tasks). + +## Limites de parallélisme + +Par défaut, un daemon traite au maximum 20 exécutions simultanées, et chaque agent au maximum 6. Le parallélisme effectif correspond à la plus petite de ces deux valeurs. + +Une fois une limite atteinte, les nouvelles exécutions restent en file d'attente. Vous pouvez ajuster le parallélisme d'un agent donné dans ses paramètres, et le plafond global de la machine via `MULTICA_DAEMON_MAX_CONCURRENT_TASKS`. Les exécutions en parallèle se disputent en même temps la capacité de la machine, le quota du compte de l'outil et le même répertoire de travail. + +## Runtimes privés et publics + +Un runtime local est **privé** par défaut : seul le propriétaire du runtime peut y créer des agents. Les propriétaires et administrateurs de l'espace de travail ne font pas exception — le runtime est l'ordinateur de quelqu'un d'autre, et y exécuter un agent consomme sa machine et ses identifiants d'outils. + +Seul le propriétaire du runtime peut le rendre **public** — les administrateurs de l'espace de travail peuvent renommer ou supprimer un runtime, mais le partager relève de la décision du propriétaire. Les autres membres de l'espace de travail peuvent alors eux aussi sélectionner ce runtime ; cela ne partage pas les identifiants de connexion de l'outil de codage IA sous-jacent — cela permet seulement aux membres d'acheminer les exécutions de leurs agents vers cet ordinateur. + +## Profils de runtime personnalisés + +Si votre équipe utilise un wrapper interne, un exécutable à version figée, ou a besoin d'arguments supplémentaires fixes pour un outil compatible, créez un **profil de runtime personnalisé**. + +Un profil personnalisé n'ajoute pas de nouveau protocole de communication. Vous choisissez toujours l'une des familles de protocoles que Multica prend déjà en charge (le type de protocole d'intégration de l'outil ; voir [Comparatif des outils de codage IA](/providers)), et la commande elle-même doit être compatible avec cette famille. + +### Environnement du runtime pendant une exécution + +Lorsque le daemon démarre l'exécution d'un agent, il injecte le contexte de l'exécution dans le processus du runtime. Ces valeurs appartiennent au daemon : l'environnement personnalisé d'un agent ne peut remplacer aucune variable `MULTICA_` ni les variables de répertoire temporaire de l'exécution. + +Le tableau liste les variables que les runtimes personnalisés peuvent utiliser aujourd'hui. Il n'est volontairement pas exhaustif et ne constitue pas une surface d'API versionnée. Ne construisez vos intégrations que sur les cinq variables marquées **contrat d'intégration** ; considérez les autres comme des informations indicatives susceptibles de changer. + +| Variable | Valeur pendant une exécution | Stabilité | +| --- | --- | --- | +| `MULTICA_TOKEN` | Jeton d'API `mat_` limité à l'exécution | **Contrat d'intégration** | +| `MULTICA_TASK_ID` | ID de l'exécution en cours | **Contrat d'intégration** | +| `MULTICA_AGENT_ID` | ID de l'agent assigné | **Contrat d'intégration** | +| `MULTICA_WORKSPACE_ID` | ID de l'espace de travail de l'exécution | **Contrat d'intégration** | +| `MULTICA_SERVER_URL` | URL du serveur Multica sélectionnée par le daemon | **Contrat d'intégration** | +| `MULTICA_TASK_CONFIG_ROOT` | Racine privée de configuration du CLI Multica, propre à l'exécution | Indicatif | +| `MULTICA_TASK_WORKSPACES_ROOT` | Racine des répertoires de travail des exécutions gérés par le daemon | Indicatif | +| `MULTICA_AGENT_NAME` | Nom affiché de l'agent assigné | Indicatif | +| `MULTICA_DAEMON_PORT` | Port local de santé/API du daemon utilisé par les commandes réservées aux exécutions, comme `multica repo checkout` | Indicatif | +| `MULTICA_TASK_SLOT` | Emplacement dans le pool de parallélisme global du daemon ; utile pour les ressources indexées par emplacement, comme les GPU | Indicatif | +| `TMPDIR` | Répertoire temporaire privé de l'exécution en cours ; également fourni sous `TMP` et `TEMP` pour les outils multiplateformes | Indicatif | + +Le serveur détermine l'auteur des requêtes effectuées avec `MULTICA_TOKEN` ; les écritures telles que les commentaires de tâche sont attribuées à l'agent assigné et à l'exécution en cours. Pour tout savoir sur le rattachement du jeton, ses permissions, l'attribution, sa durée de vie maximale de 24 heures et son nettoyage, consultez [Jetons temporaires pour les exécutions d'agents](/auth-tokens#jetons-temporaires-pour-les-exécutions-dagents). + + +Ces valeurs se trouvent dans l'environnement réel du processus du runtime. Tout processus enfant lancé par le runtime hérite par défaut de toutes ces valeurs, y compris `MULTICA_TOKEN`. Si un processus enfant ne doit pas détenir cet identifiant, retirez-le explicitement ; ne comptez pas sur une isolation des processus qui n'existe pas. L'exception va dans l'autre sens : les outils qui filtrent l'environnement de leurs propres sous-processus peuvent exiger une règle d'autorisation explicite. Par exemple, l'outil shell de Codex écarte les noms contenant `TOKEN`, `KEY` ou `SECRET` ; le daemon installe donc une politique shell gérée qui autorise les variables d'exécution requises. Conservez le jeton uniquement dans les environnements de processus — jamais dans un prompt, un journal, un fichier du dépôt ou une configuration persistante. Un processus enfant partage l'identité et les permissions de l'exécution parente ; il ne reçoit pas de nouvelle identité à la portée indépendante. + + +### Créer un profil + +Seuls les propriétaires et administrateurs de l'espace de travail peuvent créer, modifier ou supprimer des profils de runtime personnalisés : + +1. Ouvrez **Runtimes** et accédez à un ordinateur sur lequel la commande est installée. +2. Cliquez sur **Ajouter un runtime personnalisé**. +3. Choisissez la famille de protocoles avec laquelle la commande est réellement compatible. +4. Renseignez le nom, la commande et les arguments fixes, puis enregistrez. + +Le profil est partagé dans tout l'espace de travail. Chaque ordinateur connecté recherche la commande de son côté ; seuls les ordinateurs capables de la résoudre dans le `PATH` enregistrent le runtime correspondant. Créer un profil n'installe pas la commande et ne connecte pas les autres membres à l'outil. + +Le champ de commande accepte un exécutable et des arguments, pas un script shell. Les arguments simples, les guillemets et les échappements par barre oblique inverse fonctionnent ; les tubes, les redirections, `&&`, `;`, les accents graves (backticks) et l'expansion de variables d'environnement ne fonctionnent pas. Si vous avez besoin de ces comportements, placez-les dans un script d'encapsulation et utilisez ce script comme commande. + +### Ordre de vos arguments + +Tout ce que vous saisissez dans le champ de commande reste directement après l'exécutable, avant les arguments ajoutés par Multica : + +```text + +``` + +C'est ce qui permet à un wrapper à sous-commandes de fonctionner. Si votre commande est `ccms start q36`, l'outil voit d'abord `start q36` et peut sélectionner sa sous-commande avant que n'arrivent le `-p` de Multica et le reste — c'est le seul ordre qu'accepte ce type de wrapper. + +Auparavant, vos arguments étaient ajoutés en dernier. La plupart des commandes à options les analysent de la même façon dans les deux cas, mais pas toutes — une commande qui distingue les options globales des options de sous-commande peut être sensible à la position d'une option. Si vous avez déjà un profil avec des arguments fixes, lancez une exécution dessus après la mise à jour pour vérifier qu'il fonctionne toujours. + +Deux autres conséquences à connaître : + +- **Les valeurs de Multica l'emportent en cas de conflit.** Si vos arguments fixes définissent une option que Multica définit aussi, la valeur de Multica arrive plus tard et prend effet. Surtout, un modèle choisi sur l'agent remplace un `--model` figé dans le profil. Pour imposer un modèle à tout le monde, laissez vide le champ du modèle des agents. +- **Les options critiques pour le protocole sont ignorées.** `-p`, `--output-format`, `--input-format`, `--permission-mode` et leurs équivalents pour les autres familles sont retirés de vos arguments fixes, car les remplacer romprait la connexion du daemon à l'outil. Les sous-commandes et les autres arguments positionnels sont toujours transmis. + +Si un daemon lancé par Desktop ne trouve pas une commande que votre terminal peut exécuter, définissez un chemin absolu pour l'ordinateur courant : + +```bash +multica runtime profile set-path --path /absolute/path/to/command +``` + +Supprimer le remplacement de chemin : + +```bash +multica runtime profile unset-path +``` + +Modifier un profil n'affecte que les exécutions prises en charge par la suite. Avant de supprimer un profil, occupez-vous des agents actifs encore liés à ses runtimes. Supprimer uniquement l'instance de runtime sur un ordinateur ne supprime pas le profil — un daemon en cours d'exécution le réenregistrera. + +## Dépanner un runtime hors ligne + +Vérifiez dans cet ordre : + +1. Exécutez `multica daemon status` pour confirmer que le daemon tourne. +2. Exécutez `multica daemon logs -f` pour rechercher des erreurs d'authentification, de réseau ou de détection d'outils. +3. Exécutez `command -v ` dans le même environnement pour confirmer que le daemon peut trouver l'outil. +4. Ouvrez la page **Runtimes** de Multica et vérifiez que l'ordinateur cible et l'outil de codage IA correspondant apparaissent en ligne. +5. Après l'installation d'un outil, une modification du chemin ou la mise à jour d'un profil, exécutez `multica daemon restart`. + +Si le problème persiste, consultez [Dépannage](/troubleshooting). + +## Étapes suivantes + +- [Installer les outils de codage IA](/install-agent-runtime) — installer et vérifier un outil de codage IA pris en charge. +- [Comparatif des outils de codage IA](/providers) — comparer les modèles, la reprise de session, MCP et la prise en charge des skills. +- [Exécutions](/tasks) — file d'attente, arrêt et relances. diff --git a/apps/docs/content/docs/desktop-app.fr.mdx b/apps/docs/content/docs/desktop-app.fr.mdx new file mode 100644 index 00000000000..b422865a8b6 --- /dev/null +++ b/apps/docs/content/docs/desktop-app.fr.mdx @@ -0,0 +1,182 @@ +--- +title: Application de bureau +description: Installez Multica Desktop, utilisez les onglets de Desktop et le daemon intégré, et connectez-vous à une instance auto-hébergée. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Multica Desktop est disponible pour macOS, Windows et Linux. Il utilise le même compte et les mêmes données d'espace de travail que l'application web, mais il gère automatiquement le daemon sur votre machine et conserve un ensemble d'onglets distinct pour chaque espace de travail. + +## Desktop et web + +| | Web | Desktop | +| --- | --- | --- | +| Comment l'ouvrir | Navigateur | Installer l'application de bureau | +| Onglets par espace de travail | Utilise les onglets du navigateur | Chaque espace de travail conserve ses propres onglets | +| Daemon | Installer et démarrer le CLI séparément | Démarré automatiquement par l'application après la connexion | +| Mises à jour | Actualiser la page | Mises à jour via l'application de bureau | + +Le web est plus pratique pour un coup d'œil rapide ou sur un ordinateur partagé. + +Vous pouvez être connecté aux deux en même temps. Tant qu'ils se connectent au même service Multica, les données qu'ils affichent sont partagées. + +## Installation + +Choisissez le programme d'installation correspondant à votre système d'exploitation et à l'architecture de votre processeur sur la [page de téléchargement de Multica](https://multica.ai/download) : + +| Plateforme | Programme d'installation | +| --- | --- | +| macOS | `.dmg` | +| Windows | `.exe` | +| Linux | `.AppImage`, `.deb` ou `.rpm` | + +Après l'installation, connectez-vous avec la même adresse e-mail que sur le web. Une fois connecté, Desktop démarre son propre CLI Multica intégré et détecte les outils de codage IA déjà installés sur la machine. + + +Le CLI intégré à Desktop sert uniquement au runtime géré par l'application. Si vous voulez aussi exécuter des commandes comme `multica issue` dans un terminal, installez le CLI séparément en suivant l'[étape 2 du démarrage rapide](/cloud-quickstart#2-connectez-un-ordinateur). + + +## Onglets de Desktop + +Desktop enregistre les onglets par espace de travail. Par exemple, si vous ouvrez trois tâches dans l'espace de travail A puis passez à l'espace de travail B, vous voyez les onglets propres à B ; revenez à A et les trois onglets précédents sont toujours là. + +Une même ressource ne s'ouvre qu'une seule fois dans l'espace de travail courant. Les onglets peuvent être réordonnés, épinglés et fermés, et chacun conserve son propre historique de navigation avant/arrière et sa position de défilement. Cliquer sur un lien de l'application pour ce déploiement, ou le coller, l'ouvre directement dans un onglet au lieu de basculer vers le navigateur. + +La déconnexion efface tous les onglets enregistrés sur la machine : la personne suivante qui se connecte ne voit jamais les pages laissées par le compte précédent. + +## Daemon intégré + +Après la connexion, Desktop crée un profil CLI dédié au service Multica courant et l'utilise pour démarrer le daemon. Le profil se trouve ici : + +```text +~/.multica/profiles/desktop-/ +``` + +Il ne lit ni n'écrase jamais le profil par défaut que vous utilisez dans le terminal. Si vous démarrez aussi manuellement un autre daemon, Multica les affiche comme des runtimes distincts. + +Vous pouvez consulter l'état et les journaux du runtime dans les paramètres de Desktop. Si un outil n'est pas détecté, vérifiez d'abord qu'il fonctionne dans un terminal ordinaire et qu'il est connecté, puis redémarrez le daemon de Desktop. + +## Mettre à jour Desktop + +La mise à jour automatique est activée par défaut. L'application recherche et télécharge les nouvelles versions en arrière-plan ; une fois une version téléchargée, vous pouvez redémarrer pour l'installer immédiatement ou la laisser s'installer à la prochaine fermeture. Vous pouvez aussi désactiver les vérifications automatiques ou rechercher manuellement de nouvelles versions dans **Paramètres → Mises à jour**. + +Les mises à jour sont distribuées par système d'exploitation et par architecture de processeur : + +- Windows arm64 et macOS x64 (Intel) utilisent chacun leur propre flux de mise à jour ; +- toutes les autres architectures utilisent le flux par défaut ; +- l'application sélectionne automatiquement le bon flux de mise à jour — aucun choix manuel n'est nécessaire ; +- sous Linux, la mise à jour automatique ne fonctionne qu'avec `.AppImage` ; mettez à jour `.deb` et `.rpm` en installant un nouveau paquet par-dessus l'ancien ; +- si la mise à jour automatique échoue, réinstallez de la même façon par-dessus avec le programme d'installation correspondant, disponible sur la page de téléchargement. + +## Se connecter à une instance auto-hébergée + +Desktop se connecte par défaut à Multica Cloud. Pour vous connecter à une instance auto-hébergée, créez `desktop.json` dans le répertoire `.multica` de votre répertoire personnel : + +| Plateforme | Chemin | +| --- | --- | +| macOS | `/Users//.multica/desktop.json` | +| Linux | `/home//.multica/desktop.json` | +| Windows | `C:\Users\\.multica\desktop.json` | + +Ce sont les emplacements par défaut — si votre répertoire personnel a été déplacé ou redirigé, utilisez son chemin réel. + +```json +{ + "schemaVersion": 1, + "apiUrl": "https://api.example.com" +} +``` + + +Il ne s'agit **pas** du `~/.multica/config.json` du CLI, et les noms de clés diffèrent : le CLI utilise `server_url`, Desktop utilise `apiUrl`. Desktop ne lit jamais la configuration du CLI — il gère un profil de daemon distinct dans `~/.multica/profiles/desktop-/`. Modifier `config.json` ne change pas le serveur auquel Desktop se connecte. + + +`apiUrl` est l'adresse publique du backend ; elle est obligatoire et doit utiliser `http` ou `https`. Les deux autres URL peuvent être omises — Desktop les déduit automatiquement : + +- `wsUrl` : remplace le schéma de `apiUrl` par `ws` ou `wss` et ajoute `/ws` au chemin ; +- `appUrl` : retire le préfixe `api.` lorsque l'hôte commence par `api.` et comporte au moins trois niveaux (`api.example.com` → `example.com`) ; sinon, elle reste identique à `apiUrl`. + +En général, `apiUrl` seul suffit. Ne les définissez explicitement que si la déduction ne correspond pas à votre déploiement ; les trois cas courants : + +- l'application web et l'API sont sur des domaines différents ; +- le WebSocket est déployé séparément ; +- l'hôte ne suit pas la convention de retrait du préfixe (les hôtes à deux niveaux comme `api.local` conservent leur préfixe). + +```json +{ + "schemaVersion": 1, + "apiUrl": "https://api.example.com", + "appUrl": "https://app.example.com", + "wsUrl": "wss://ws.example.com/socket" +} +``` + +Redémarrez Desktop après l'enregistrement — le fichier n'est lu qu'une fois, au démarrage. Les deux modes d'échec se manifestent différemment, ce qui est le moyen le plus rapide de les distinguer : + +- **Fichier introuvable** (mauvais répertoire, ou nom de fichier qui n'est pas exactement `desktop.json`) — Desktop utilise la configuration Cloud par défaut et n'affiche aucune erreur. Donc, si Desktop indique toujours une adresse Cloud sans aucune erreur de configuration, le fichier ne se trouve pas là où Desktop le cherche. +- **Fichier trouvé mais invalide** (JSON, version ou URL) — Desktop affiche une erreur de configuration et ne se rabat pas sur Cloud. + +Supprimez le fichier et redémarrez pour revenir à la configuration Cloud par défaut. + + +Desktop ne peut se connecter qu'à des adresses joignables à la fois depuis le navigateur et depuis la machine d'exécution. Si une instance auto-hébergée distante n'utilise pas HTTPS ou ne fait pas passer le WebSocket par son proxy, Desktop ne peut pas établir de connexion ; consultez le [démarrage rapide en auto-hébergement](/self-host-quickstart#3-choisir-le-mode-daccès) pour la configuration complète. + + +### Windows : vérifier le nom et l'encodage du fichier + +Deux comportements par défaut des éditeurs Windows produisent silencieusement un fichier que Desktop ne peut pas utiliser : + +- **Le Bloc-notes ajoute `.txt`.** Enregistrer sous `desktop.json` peut produire `desktop.json.txt`, que Desktop considère comme introuvable. Activez l'affichage des extensions de noms de fichiers dans l'Explorateur de fichiers, ou choisissez *Tous les fichiers* dans la boîte de dialogue d'enregistrement. Pour voir le vrai nom du fichier, exécutez `Get-ChildItem "$env:USERPROFILE\.multica" -Filter "desktop.json*"` dans PowerShell — la colonne `Name` doit indiquer exactement `desktop.json`. +- **La redirection PowerShell écrit en UTF-16 ou avec un BOM.** `> desktop.json` et `Out-File` peuvent produire un encodage impossible à analyser, qui se traduit par une erreur de configuration. + +Pour éviter ces deux problèmes, créez le fichier depuis PowerShell en une seule étape : + +```powershell +$dir = "$env:USERPROFILE\.multica" +New-Item -ItemType Directory -Force $dir | Out-Null +$json = @' +{ + "schemaVersion": 1, + "apiUrl": "https://api.example.com" +} +'@ +[System.IO.File]::WriteAllText("$dir\desktop.json", $json) +``` + +## Windows Defender signale Multica comme un virus + +**Symptôme** : Sécurité Windows signale une menace telle que `Trojan:Script/Wacatac.B!ml` et met en quarantaine un fichier de l'installation de Desktop. L'élément concerné est normalement le CLI intégré, et non l'exécutable de l'application : + +```text +C:\Users\\AppData\Local\Programs\@multicadesktop\resources\app.asar.unpacked\resources\bin\multica.exe +``` + +**Il s'agit d'un faux positif.** Le suffixe `!ml` signifie que le verdict provient des heuristiques d'apprentissage automatique de Defender, et non d'une signature de logiciel malveillant. Les builds Windows de Multica ne sont pas encore signées avec Authenticode, et un binaire non signé tout juste publié, qui lance des processus en arrière-plan et ouvre des connexions réseau, correspond exactement au profil que ces heuristiques jugent suspect. Chaque artefact Windows est construit par GitHub Actions à partir du code source public de ce dépôt. + +**Vérifiez par vous-même** — chaque release GitHub publie un fichier `checksums.txt` couvrant toutes les archives du CLI : + +```powershell +Get-FileHash .\multica-cli--windows-amd64.zip -Algorithm SHA256 +``` + +Comparez l'empreinte obtenue avec la ligne correspondante de `checksums.txt` dans la [dernière release](https://github.com/multica-ai/multica/releases/latest). + +**Comment corriger** : + +1. **Restaurez le fichier mis en quarantaine** — Sécurité Windows → *Protection contre les virus et menaces* → *Historique de protection* → sélectionnez l'élément Multica → *Actions* → *Restaurer*. +2. **Ajoutez une exclusion** pour qu'il ne soit plus mis en quarantaine — *Protection contre les virus et menaces* → *Gérer les paramètres* → *Exclusions* → *Ajouter une exclusion* → *Dossier*, et ajoutez les deux dossiers suivants : + - `%LOCALAPPDATA%\Programs\@multicadesktop` + - `%APPDATA%\Multica` + + Les deux chemins sont nécessaires : lorsque le CLI intégré disparaît, Desktop télécharge un remplaçant dans `%APPDATA%\Multica\bin` ; exclure uniquement le dossier d'installation vous enferme donc dans une boucle où le remplaçant est lui aussi mis en quarantaine. +3. **Signalez le faux positif à Microsoft** sur [Microsoft Security Intelligence — soumettre un fichier](https://www.microsoft.com/en-us/wdsi/filesubmission), en choisissant *Software developer* et *Incorrectly detected as malware*. C'est ce qui permet de retirer la détection pour tous les utilisateurs, généralement en quelques jours. + + +N'ajoutez ces exclusions que si vous avez installé Multica depuis la [page de téléchargement](https://multica.ai/download) ou les [releases GitHub](https://github.com/multica-ai/multica/releases) et que la somme de contrôle correspond. Une exclusion désactive la protection en temps réel pour tout le contenu de ce dossier. + + +## Étapes suivantes + +- [Démarrage rapide](/cloud-quickstart) — le parcours de première connexion. +- [Daemon et runtimes](/daemon-runtimes) — le fonctionnement du daemon intégré. +- [Dépannage](/troubleshooting) — diagnostiquer les problèmes de connexion. diff --git a/apps/docs/content/docs/developers/architecture.fr.mdx b/apps/docs/content/docs/developers/architecture.fr.mdx new file mode 100644 index 00000000000..d5b28669a87 --- /dev/null +++ b/apps/docs/content/docs/developers/architecture.fr.mdx @@ -0,0 +1,131 @@ +--- +title: Architecture du projet +description: Découvrez comment les clients de Multica, le service Go, le daemon d'exécution et les packages frontend partagés fonctionnent ensemble. +--- + +Multica se compose d'un backend Go, de plusieurs clients et d'un daemon qui tourne sur les ordinateurs d'exécution. PostgreSQL stocke les données de collaboration ; le daemon récupère les tasks et invoque les outils de codage IA locaux. + +Cette page, centrée sur l'implémentation, emploie **task** pour désigner l'entité interne de l'ordonnanceur, de l'API et de la base de données qui se trouve derrière une **exécution** du produit. L'interface et la documentation produit parlent d'exécution ; les identifiants comme `TaskService` et `task_id` restent inchangés pour des raisons de compatibilité. + +```text +Web / Desktop / Mobile / CLI + │ + HTTP + WebSocket + │ + Go API ───────── PostgreSQL + │ + daemon WebSocket + │ + daemon local ─── outils de codage IA +``` + +Pour une explication orientée produit, consultez [Fonctionnement de Multica](/how-multica-works). Cette page se concentre sur l'organisation du code en couches. + +## Organisation du dépôt + +| Répertoire | Responsabilité | Technologies principales | +| --- | --- | --- | +| `server/` | API, authentification, ordonnancement des tasks, intégrations, CLI et daemon | Go, Chi, sqlc, gorilla/websocket | +| `apps/web/` | Client navigateur et page d'accueil | Next.js App Router | +| `apps/desktop/` | Client de bureau et gestion des processus locaux | Electron, electron-vite | +| `apps/mobile/` | Client iOS autonome | Expo, React Native | +| `apps/docs/` | Site de documentation multilingue | Next.js, Fumadocs | +| `packages/core/` | Client API, types, queries, mutations et logique métier indépendante de la plateforme | TanStack Query, Zustand | +| `packages/ui/` | UI de base sans logique métier | shadcn, Base UI | +| `packages/views/` | Pages et composants métier partagés par Web et Desktop | React | +| `packages/tsconfig/`, `packages/eslint-config/` | Configuration d'outillage partagée | TypeScript, ESLint | + +Les packages partagés exportent directement des fichiers source `.ts` et `.tsx`, compilés par les applications qui les consomment. Le sens des dépendances est `views → core + ui` ; `core` et `ui` ne dépendent pas l'un de l'autre. + +## Partage de code entre Web et Desktop + +Web et Desktop partagent trois couches : + +1. `packages/core` gère les API, le cache, les permissions et l'état indépendant de la plateforme. +2. `packages/ui` fournit les composants de base. +3. `packages/views` compose les pages métier. + +Les capacités propres à chaque plateforme, comme le routage, les cookies et l'IPC Electron, restent dans la couche applicative. Les pages partagées naviguent via `NavigationAdapter` et n'importent pas directement `next/*` ni `react-router-dom`. + +Par exemple, une fonctionnalité liée aux tâches dont Web et Desktop ont tous deux besoin touche généralement : + +```text +packages/core/issues/ queries, mutations, mises à jour du cache +packages/views/issues/ pages et composants métier +apps/web/platform/ adaptateur de routage Next.js +apps/desktop/.../platform/ adaptateur de routage Electron +``` + +Mobile ne réutilise pas ces pages React. Il peut importer des types et des fonctions pures depuis `@multica/core`, mais il possède sa propre UI, ses propres clés de query, son état, ses abonnements temps réel et son processus de publication. + +## État frontend + +Les données serveur et l'état client sont gérés séparément : + +- **TanStack Query** détient les données serveur, comme les tâches, les agents, les membres et les éléments de la boîte de réception. +- **Zustand** détient l'état client, comme les filtres, les brouillons, les boîtes de dialogue et la mise en page. +- L'espace de travail courant est déterminé par la route et n'est reflété dans la couche plateforme que là où les requêtes, les espaces de noms persistés ou la reconnexion l'exigent. +- React Context ne transporte que la plomberie de la plateforme, comme l'ID de l'espace de travail et l'adaptateur de navigation. + +Les événements WebSocket doivent mettre à jour ou invalider le cache TanStack Query. Ne copiez pas les objets serveur dans Zustand. Les mutations qui entraînent une navigation, comme la création, la suppression ou le départ d'un espace de travail, doivent attendre la confirmation du serveur avant d'effacer l'état local. + +Les réponses de l'API sont analysées avec des schémas zod à la frontière `packages/core/api/`. Un client Desktop installé peut se connecter à un backend plus récent : le JSON reçu du réseau ne doit donc pas être directement converti en type TypeScript par une simple assertion. + +## Couches du backend + +Les principaux points d'entrée se trouvent dans `server/cmd/` : + +| Point d'entrée | Rôle | +| --- | --- | +| `server` | Démarre l'API HTTP, les services WebSocket, l'ordonnanceur et les workers d'intégration | +| `multica` | CLI et daemon local | +| `migrate` | Exécute les migrations de base de données | +| `backfill_*` | Outils de backfill de données pour des versions spécifiques | + +Les requêtes suivent généralement ce chemin : + +```text +router → middleware → handler → service → sqlc query → PostgreSQL +``` + +- `internal/middleware/` gère l'authentification, l'espace de travail et les limites des requêtes. +- `internal/handler/` analyse les entrées HTTP et produit les réponses. +- `internal/service/` porte les workflows métier qui couvrent plusieurs queries, ainsi que les transactions. +- `pkg/db/queries/` contient le SQL écrit à la main. +- `pkg/db/generated/` est généré par sqlc et ne doit pas être modifié directement. +- `internal/integrations/` gère les événements externes provenant de GitHub, Slack, Feishu et d'autres services. +- `internal/storage/` gère les pièces jointes locales et S3. + +PostgreSQL est la source de vérité pour les données métier. Redis est facultatif et sert aux événements temps réel entre instances, au cache ou à la coordination temporaire. Sans Redis, un environnement de développement à instance unique utilise des implémentations en processus. + +## Connexions temps réel + +Multica dispose de deux chemins WebSocket distincts : + +- `internal/realtime/` pousse vers les clients utilisateurs les modifications des tâches, des commentaires, de la boîte de réception et d'autres éléments. +- `internal/daemonws/` connecte les daemons pour réveiller les runtimes et effectuer les RPC du daemon. + +WebSocket réduit la latence, mais la base de données reste l'état final. Après une reconnexion, les clients doivent se resynchroniser au moyen de queries. Le daemon conserve aussi un chemin de polling, afin qu'une seule déconnexion ne puisse pas bloquer indéfiniment une task en file d'attente. + +## Chemin de code d'une exécution + +1. Un utilisateur assigne une tâche, mentionne un agent, ou une automatisation se déclenche. +2. `TaskService` crée une task en file d'attente et notifie le runtime correspondant. +3. Le daemon récupère la task via l'API du daemon. +4. Le serveur émet des identifiants temporaires liés à la task et à l'agent. +5. Le daemon prépare un répertoire local et invoque le backend de fournisseur correspondant dans `pkg/agent`. +6. L'outil s'exécute localement pendant que le daemon envoie la progression, les messages et le statut final. +7. Le serveur met à jour la task et la tâche, puis rafraîchit les clients via des événements temps réel. + +La couche d'adaptateurs de fournisseurs normalise le démarrage, les événements de streaming, l'annulation et les données de consommation d'un outil de codage IA à l'autre, tandis que le daemon reste responsable des répertoires locaux et des sessions. + +## Frontières entre espaces de travail + +Les queries métier doivent être limitées par `workspace_id`, et l'appartenance est vérifiée avant qu'une requête n'entre dans une route d'espace de travail. `X-Workspace-ID` sélectionne l'espace de travail courant mais ne remplace pas les vérifications d'autorisation. + +L'assigné d'une tâche est polymorphe et peut désigner un membre, un agent ou un squad. Les nouvelles queries, clés de cache et événements temps réel doivent conserver à la fois l'espace de travail et le type d'assigné, au lieu de supposer qu'un ID de ressource suffit à constituer un contexte globalement unique. + +## Étapes suivantes + +- [Contribuer](/developers/contributing) — environnements locaux, worktrees et emplacement des tests. +- [Conventions de développement](/developers/conventions) — les contrats du dépôt en matière de nommage, de terminologie et de textes en chinois. diff --git a/apps/docs/content/docs/developers/contributing.fr.mdx b/apps/docs/content/docs/developers/contributing.fr.mdx new file mode 100644 index 00000000000..fbb30117150 --- /dev/null +++ b/apps/docs/content/docs/developers/contributing.fr.mdx @@ -0,0 +1,213 @@ +--- +title: Contribuer +description: Configurez un environnement de développement Multica local, exécutez les tests et soumettez vos modifications en suivant les conventions du dépôt. +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Multica est composé d'un backend Go et d'un monorepo pnpm. Le moyen le plus simple de démarrer en local est `make dev` : cette commande prépare l'environnement, la base de données et les migrations pour le checkout courant, puis démarre Web et l'API. + +## Prérequis + +- Node.js 22 +- pnpm 10.28.2 +- Go 1.26.6 +- Docker Engine ou Docker Desktop +- Git et Make + +Le `package.json` racine, `server/go.mod` et les workflows de CI font foi pour les versions. + +## Premier démarrage + +```bash +git clone https://github.com/multica-ai/multica.git +cd multica +make dev +``` + +Le checkout principal utilise `.env`. Si ce fichier n'existe pas, `make dev` le crée à partir de `.env.example`, puis démarre l'instance PostgreSQL partagée, installe les dépendances, exécute les migrations et démarre l'API et Web. + +Adresses par défaut : + +```text +Web: http://localhost:3000 +API: http://localhost:8080 +``` + +Le code de vérification local fixe provient de la configuration de développement. N'utilisez pas un `.env` local pour un déploiement exposé à Internet. + +## Développer dans un worktree + +Le dépôt permet d'exécuter le checkout principal et plusieurs worktrees en même temps. Ils partagent un même conteneur PostgreSQL, mais utilisent des bases de données et des ports distincts. + +```bash +git worktree add ../multica-feature -b feat/my-change main +cd ../multica-feature +make setup-worktree +make start-worktree +``` + +`make setup-worktree` génère `.env.worktree` ; le nom de la base de données et les ports sont dérivés du chemin. Pour le redémarrer : + +```bash +make start-worktree +``` + +Pour arrêter Web et l'API du worktree courant : + +```bash +make stop-worktree +``` + +Vous pouvez aussi exécuter `make dev` directement. Le script détecte un worktree grâce à son fichier `.git` et sélectionne `.env.worktree`. + + +Les différents worktrees partagent le conteneur PostgreSQL, pas la base de données. Ne démarrez pas un nouveau projet Compose pour chaque worktree. Vérifiez d'abord `POSTGRES_DB`, `PORT` et `FRONTEND_PORT` dans `.env.worktree`. + + +## Commandes courantes + +### Workflow complet + +```bash +make up # Démarre l'environnement de ce checkout (C=api,web,daemon,desktop) +make status # Affiche ce qui tourne et prouve qu'il s'agit de cet environnement +make list # Liste tous les environnements de cette machine +make down # Arrête les processus, conserve la base de données +make destroy # Arrête, puis supprime la base de données et libère l'emplacement +make gc # Nettoie les environnements expirés ou dont le répertoire n'existe plus +make dev # Prépare et démarre le checkout courant au premier plan +make check # Exécute le workflow complet de vérification locale +make build # Compile les binaires server, CLI et migrate +``` + +`make up` traite un environnement comme un objet nommé : il alloue sous verrou les ports de l'API, de Web et du renderer Desktop, +le nom de la base de données et le profil CLI, puis les enregistre dans `~/.multica/dev/`, +de sorte que deux checkouts ne peuvent pas occuper le même emplacement sans que l'un d'eux en soit averti. +Il vérifie la base de données via `DATABASE_URL` et le backend via +`GET /health`, qui renvoie `pid`, `commit` et `started_at` — une simple réponse 200 +pourrait aussi provenir d'un processus résiduel sur le même port. `make destroy` +supprime la base de données, le profil CLI, les espaces de travail du daemon, le userData de Desktop et +l'entrée du registre ; en cas d'échec de la suppression, l'entrée du registre est conservée pour une nouvelle tentative. Les environnements +temporaires sont nettoyés au mieux lors du `make up` suivant l'expiration de leur TTL. + +### Frontend + +```bash +pnpm install +pnpm dev:web +pnpm dev:desktop +pnpm build +pnpm typecheck +pnpm lint +pnpm test +``` + +Les commandes racine excluent Mobile par défaut. Mobile dispose de ses propres scripts et de sa propre CI ; lisez `apps/mobile/AGENTS.md` avant de le modifier. + +### Backend + +```bash +make server +make daemon +make test +make migrate-up +make migrate-down +make sqlc +``` + +Pour exécuter une commande CLI depuis les sources : + +```bash +make cli ARGS="issue list" +``` + +## Modifier des fonctionnalités frontend + +Placez les fonctionnalités nécessaires à la fois à Web et à Desktop selon leur responsabilité : + +1. Placez les types d'API, les queries, les mutations et la logique indépendante de la plateforme dans `packages/core/`. +2. Placez l'UI de base dans `packages/ui/` ; elle ne doit pas dépendre du code métier. +3. Placez les pages et composants métier dans `packages/views/`. +4. Conservez les adaptateurs Next.js, Electron et de routage dans l'application correspondante. +5. Branchez les pages partagées à la fois dans Web et dans Desktop. + +TanStack Query gère les données serveur. Zustand gère l'état client, comme les filtres, les brouillons et la mise en page. Consultez [Architecture du projet](/developers/architecture) et le `AGENTS.md` racine pour connaître la frontière exacte. + +Lorsque vous ajoutez ou modifiez une API, mettez à jour le schéma zod dans `packages/core/api/` et ajoutez des tests de parsing pour les champs manquants, les valeurs d'enum inconnues et les données malformées. + +## Modifier la base de données + +Les migrations se trouvent dans `server/migrations/`, et les requêtes dans `server/pkg/db/queries/`. + +1. Utilisez le prochain préfixe numérique inutilisé et créez à la fois `.up.sql` et `.down.sql`. +2. N'ajoutez pas de clés étrangères en base de données, ni de suppressions ou de mises à jour en cascade. Garantissez les relations et le nettoyage dans la couche applicative. +3. Tout nouvel index doit utiliser `CREATE INDEX CONCURRENTLY` ou `CREATE UNIQUE INDEX CONCURRENTLY`. +4. Placez chaque index concurrent dans un fichier de migration qui ne contient que cette instruction. +5. Exécutez `make sqlc` après avoir modifié des requêtes et committez les modifications générées dans `server/pkg/db/generated/`. +6. Ne modifiez pas directement les fichiers générés par sqlc. + +Utilisez une transaction applicative dans la couche service lorsque plusieurs écritures doivent réussir ou être annulées ensemble. + +## Emplacement des tests + +| Modification | Emplacement des tests | +| --- | --- | +| Logique métier partagée, queries, stores | `packages/core/*.test.ts` | +| Pages et composants partagés | `packages/views/*.test.tsx` | +| Intégration à la plateforme Web ou Desktop | Répertoire `apps/*` correspondant | +| Workflows de bout en bout | `e2e/*.spec.ts` | +| Backend | `*_test.go` dans le package Go concerné | + +Exécutez d'abord la vérification la plus proche de la modification, puis élargissez la portée. Pour une modification qui ne touche que Docs : + +```bash +pnpm --filter @multica/docs typecheck +``` + +Pour les modifications frontend partagées : + +```bash +pnpm typecheck +pnpm test +``` + +Pour les modifications backend : + +```bash +make test +``` + +Avant de soumettre : + +```bash +make check +``` + +`make check` exécute la vérification des types TypeScript et les tests unitaires, les tests Go et les tests E2E Playwright. La CI compile et exécute aussi le lint selon la portée des modifications, et lance des tests propres à une plateforme ou à l'installateur. + +## Réinitialiser la base de données de développement courante + +Lorsque vous avez besoin de données propres, réinitialisez la base de données désignée par le **fichier d'environnement du checkout courant** : + +```bash +make stop +make db-reset +make start +``` + +`make db-reset` supprime puis recrée la base `POSTGRES_DB` courante et refuse de se connecter à une base de données distante. Avant de l'exécuter, vérifiez `.env` ou `.env.worktree` et confirmez la base de données cible. + +## Avant de soumettre + +- Lisez le `AGENTS.md` racine et les instructions imbriquées pertinentes. +- Limitez vos modifications au périmètre nécessaire. +- Rédigez les commentaires de code en anglais. +- Ne committez pas `.env`, des jetons, des artefacts de build ni des chemins locaux. +- Utilisez des conventional commits tels que `feat(scope)`, `fix(scope)` ou `docs`. +- Dans la PR, décrivez les changements de comportement et les commandes de vérification réellement exécutées. + +## Étapes suivantes + +- [Conventions de développement](/developers/conventions) — Contrats du dépôt pour le nommage, la terminologie et les textes en chinois. +- [Architecture du projet](/developers/architecture) — Couches, packages partagés et chemin du code pour une exécution. diff --git a/apps/docs/content/docs/developers/conventions.fr.mdx b/apps/docs/content/docs/developers/conventions.fr.mdx new file mode 100644 index 00000000000..392d08a2166 --- /dev/null +++ b/apps/docs/content/docs/developers/conventions.fr.mdx @@ -0,0 +1,338 @@ +--- +title: Conventions +description: Source unique de vérité pour le nommage du code, le glossaire de traduction i18n et le guide de ton pour le chinois. +--- + +Cette page est la source unique de vérité pour le nommage du code, le glossaire de traduction i18n et le guide de ton pour le chinois. Tout ce qui se trouvait auparavant dans `packages/views/locales/glossary.md` ou dans des commentaires épars se trouve désormais ici. + +Si vous écrivez du code Multica, modifiez une traduction ou rédigez des textes produit en chinois, c'est la page de référence. + +--- + +## 1. Nommage du code + +### Routes + +Les routes pré-espace de travail (celles qui existent avant que l'utilisateur n'entre dans un espace de travail) DOIVENT utiliser soit un seul mot, soit le motif `/{noun}/{verb}`. + +- ✅ `/login`, `/inbox`, `/workspaces/new` +- ❌ `/new-workspace`, `/create-team`, `/accept-invite` + +Les groupes de mots reliés par des traits d'union à la racine entrent en collision avec les slugs d'espace de travail choisis par les utilisateurs et imposent des audits sans fin des slugs réservés. Réserver le nom (`workspaces`) protège automatiquement tout le sous-arbre `/workspaces/*`. + +### Routes liées à un espace de travail + +Elles se trouvent toujours sous `/{slug}/{section}` — `/{slug}/issues`, `/{slug}/agents`, `/{slug}/settings`. Ne dupliquez jamais la logique de routage des espaces de travail ; utilisez `useNavigation().push()` depuis le code partagé, jamais les API de lien propres à un framework. + +### Packages et modules + +Le monorepo impose des frontières strictes entre les packages : + +| Package | Peut dépendre de | Ne doit PAS dépendre de | +| --- | --- | --- | +| `packages/core` | rien de spécifique à une application | `react-dom`, `localStorage`, `process.env`, `next/*`, bibliothèques d'UI | +| `packages/ui` | rien | `@multica/core`, logique métier | +| `packages/views` | `core/`, `ui/` | `next/*`, `react-router-dom`, stores | +| `apps/web/platform/` | `next/*` | autres applications | +| `apps/desktop/.../platform/` | `react-router-dom`, electron | autres applications | +| `apps/mobile/` | types et fonctions pures de `@multica/core` | pages React, stores et implémentations de plateforme de Web/Desktop | + +Si une logique apparaît dans les deux applications, elle DOIT être extraite dans un package partagé. Aucune exception pour une « petite » duplication. Mobile possède sa propre UI, sa propre couche de données et son propre processus de publication ; il ne partage que les types et les fonctions pures. + +### Fichiers et composants + +- Fichiers : `kebab-case.tsx` / `kebab-case.ts` (par ex. `agent-row-actions.tsx`) +- Composants : `PascalCase` (par ex. `AgentRowActions`) +- Hooks : `useCamelCase` (par ex. `useWorkspaceId`) +- Tests : placés à côté du fichier, sous la forme `.test.ts(x)` +- Stores (Zustand) : `-store.ts`, exportés sous le nom `useStore` + +### Base de données (Go + sqlc) + +- Tables : `snake_case` au singulier (`user`, `workspace`, `agent_runtime`) +- Colonnes : `snake_case` (`workspace_id`, `created_at`, `last_seen_at`) +- Clés étrangères : `_id` +- Booléens : `is_` ou `_at` (la forme horodatée est préférée pour les changements d'état) +- Fichiers de migration : `NNN_descriptive_name.up.sql` + `.down.sql` — fournissez toujours les deux sens +- Ne créez pas de clés étrangères en base de données et n'utilisez pas de suppressions ni de mises à jour en cascade ; garantissez les relations et le nettoyage dans la couche applicative. +- Chaque index doit utiliser `CREATE INDEX CONCURRENTLY` ou `CREATE UNIQUE INDEX CONCURRENTLY`, chaque index concurrent étant placé dans son propre fichier de migration qui ne contient qu'une seule instruction. + +### Go + +- `gofmt` + `go vet` standard. Sans exception. +- Les fichiers de handler reflètent le domaine : `agent.go`, `auth.go`, `runtime.go` +- Tests : `_test.go` placé à côté du fichier +- Pour le parsing des UUID dans les handlers, suivez la règle du `AGENTS.md` racine — `parseUUIDOrBadRequest` pour les entrées aux frontières, `parseUUID` (qui panique) pour les allers-retours de confiance, et jamais `util.ParseUUID` directement sans vérifier l'erreur. + +### TypeScript + +- Les réponses d'API qui transitent sur le réseau sont en `snake_case` ; le client API les convertit en `camelCase` à la frontière. Dans le code TS, **toujours en camelCase**. +- Types : `PascalCase` (`Issue`, `AgentRuntime`) ; jamais de `IPrefix`, jamais de suffixe `_t`. +- Enums : préférez les unions de littéraux de chaîne ; réservez `enum` aux cas qui doivent pouvoir être parcourus dynamiquement. +- Clés TanStack Query : fonctions factory dans `/queries.ts`, par ex. `issueKeys.detail(id)`. + +### Frontières d'API + +- Analysez les réponses réseau avec `parseWithFallback` et les schémas zod de `packages/core/api/schema.ts` ; ne les castez pas directement avec `as T`. +- Lorsque vous ajoutez ou modifiez un endpoint, mettez à jour son schéma et couvrez les champs manquants ou malformés dans les tests. +- L'UI en aval doit fournir des valeurs par défaut pour les champs optionnels, et chaque `switch` sur un enum serveur doit inclure une branche `default`. +- Un client Desktop installé peut se connecter à un backend plus récent ; ne supposez jamais que les versions du frontend et du backend correspondent toujours. + +### Noms d'affichage des runtimes + +`AgentRuntime.name` est le nom technique brut du daemon (par ex. `Codex (host)`) ; l'alias de l'utilisateur se trouve dans `custom_name`. Un texte visible par l'utilisateur ne doit jamais afficher `runtime.name` directement — utilisez les helpers partagés pour que les alias et le fournisseur restent cohérents (MUL-5248, #5260) : + +- Libellé de runtime autonome (listes, chips, boîtes de dialogue de confirmation, titres de document) : `runtimeDisplayLabel(runtime)` → alias + fournisseur, avec repli sur le nom du daemon. +- Lorsqu'une icône ou un texte de fournisseur figure déjà à côté : `runtimeDisplayName(runtime)` → alias seul, sans répéter le fournisseur. +- Dans un groupe de machine : l'en-tête de la machine utilise `machine.title`, les lignes enfants utilisent `runtimeRowLabel(runtime, machine.title)`. +- Les sélecteurs de runtime regroupent par machine via `buildRuntimeMachines` ; ne construisez pas une liste plate de noms bruts. + +Le `runtime.name` brut n'est autorisé que pour l'identité interne — parsing du nom d'hôte, regroupement, texte indexé pour la recherche et payloads de protocole — jamais pour du texte JSX, des paramètres i18n, des libellés de `
+ + + + + + + + {l.scenarios.items.map((item) => ( + + + + + ))} + +
+ {l.scenarios.scenarioColumn} + + {l.scenarios.licenseColumn} +
+

+ {item.scenario} +

+ {item.example && ( +

+ {item.example} +

+ )} +
+ + {item.required + ? l.scenarios.required + : l.scenarios.notRequired} + +
+ + + + + ); +} diff --git a/apps/web/features/landing/components/open-source-section.tsx b/apps/web/features/landing/components/open-source-section.tsx index 3e28a36fb35..f0613105a91 100644 --- a/apps/web/features/landing/components/open-source-section.tsx +++ b/apps/web/features/landing/components/open-source-section.tsx @@ -34,6 +34,12 @@ export function OpenSourceSection() { {t.openSource.cta} + + {t.openSource.licensingCta} +
diff --git a/apps/web/features/landing/components/privacy-page-client.tsx b/apps/web/features/landing/components/privacy-page-client.tsx new file mode 100644 index 00000000000..1185cdd8c48 --- /dev/null +++ b/apps/web/features/landing/components/privacy-page-client.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { DocumentPage, DocumentSections } from "./document-page"; +import { useLocale } from "../i18n"; + +export function PrivacyPageClient() { + const { t } = useLocale(); + const p = t.privacy; + + return ( + + + + ); +} diff --git a/apps/web/features/landing/i18n/context.tsx b/apps/web/features/landing/i18n/context.tsx index e385760cc0f..55195738a55 100644 --- a/apps/web/features/landing/i18n/context.tsx +++ b/apps/web/features/landing/i18n/context.tsx @@ -11,26 +11,8 @@ import { import { useRouter } from "next/navigation"; import { useConfigStore } from "@multica/core/config"; import { createBrowserCookieLocaleAdapter } from "@multica/core/i18n/browser"; -import { createEnDict } from "./en"; -import { createJaDict } from "./ja"; -import { createKoDict } from "./ko"; -import { createZhDict } from "./zh"; -import { - toLandingDictionaryLocale, - type LandingDict, - type LandingDictionaryLocale, - type Locale, -} from "./types"; - -const dictionaryFactories: Record< - LandingDictionaryLocale, - (allowSignup: boolean) => LandingDict -> = { - en: createEnDict, - ja: createJaDict, - ko: createKoDict, - zh: createZhDict, -}; +import { createLandingDict } from "./dictionary"; +import type { LandingDict, Locale } from "./types"; type LocaleContextValue = { locale: Locale; @@ -53,7 +35,7 @@ export function LocaleProvider({ const localeAdapter = useMemo(() => createBrowserCookieLocaleAdapter(), []); const allowSignup = useConfigStore((state) => state.allowSignup); const t = useMemo( - () => dictionaryFactories[toLandingDictionaryLocale(locale)](allowSignup), + () => createLandingDict(locale, allowSignup), [allowSignup, locale], ); diff --git a/apps/web/features/landing/i18n/dictionary.test.ts b/apps/web/features/landing/i18n/dictionary.test.ts new file mode 100644 index 00000000000..f924f86c8a4 --- /dev/null +++ b/apps/web/features/landing/i18n/dictionary.test.ts @@ -0,0 +1,17 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { createLandingDict } from "./dictionary"; + +describe("createLandingDict", () => { + it.each([ + ["en", "/docs"], + ["zh-Hans", "/docs/zh"], + ["ko", "/docs/ko"], + ["ja", "/docs/ja"], + // French has no landing copy and reuses English, but its docs exist. + ["fr", "/docs/fr"], + ] as const)("links the %s footer to %s", (locale, docsHref) => { + const links = createLandingDict(locale, true).footer.groups.resources.links; + expect(links[0]?.href).toBe(docsHref); + }); +}); diff --git a/apps/web/features/landing/i18n/dictionary.ts b/apps/web/features/landing/i18n/dictionary.ts new file mode 100644 index 00000000000..aeac84ab964 --- /dev/null +++ b/apps/web/features/landing/i18n/dictionary.ts @@ -0,0 +1,33 @@ +import { docsHrefForLocale } from "@/lib/docs-href"; +import { createEnDict } from "./en"; +import { createJaDict } from "./ja"; +import { createKoDict } from "./ko"; +import { createZhDict } from "./zh"; +import { + toLandingDictionaryLocale, + type LandingDict, + type LandingDictionaryLocale, + type Locale, +} from "./types"; + +const dictionaryFactories: Record< + LandingDictionaryLocale, + (allowSignup: boolean, docsHref: string) => LandingDict +> = { + en: createEnDict, + ja: createJaDict, + ko: createKoDict, + zh: createZhDict, +}; + +// Locales without their own landing copy (e.g. French) reuse another +// dictionary, but docs links still follow the viewer's locale. +export function createLandingDict( + locale: Locale, + allowSignup: boolean, +): LandingDict { + return dictionaryFactories[toLandingDictionaryLocale(locale)]( + allowSignup, + docsHrefForLocale(locale), + ); +} diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 44a1a53c6dd..c9a1097e490 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -1,7 +1,10 @@ import { githubUrl, discordUrl } from "../components/shared"; import type { LandingDict } from "./types"; -export function createEnDict(allowSignup: boolean): LandingDict { +export function createEnDict( + allowSignup: boolean, + docsHref: string, +): LandingDict { return { header: { github: "GitHub", @@ -19,7 +22,7 @@ export function createEnDict(allowSignup: boolean): LandingDict { headlineLine1: "Your next 10 hires", headlineLine2: "won\u2019t be human.", subheading: - "Multica is an open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills \u2014 manage your human + agent workforce in one place.", + "Multica is a source-available platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills \u2014 manage your human + agent workforce in one place.", cta: "Start free trial", downloadDesktop: "Download Desktop", talkToSales: "Talk to sales", @@ -155,17 +158,18 @@ export function createEnDict(allowSignup: boolean): LandingDict { }, openSource: { - label: "Open source", - headlineLine1: "Open source", - headlineLine2: "for all.", + label: "Source available", + headlineLine1: "Every line,", + headlineLine2: "on your terms.", description: - "Multica is fully open source. Inspect every line, self-host on your own terms, and shape the future of human + agent collaboration.", + "Multica\u2019s source code is public. Inspect every line, self-host it for free, and shape the future of human + agent collaboration. Offering Multica to others as a hosted service requires a commercial license.", cta: "Star on GitHub", + licensingCta: "How licensing works \u2192", highlights: [ { title: "Self-host anywhere", description: - "Run Multica on your own infrastructure. Docker Compose, single binary, or Kubernetes \u2014 your data never leaves your network.", + "Run Multica on your own infrastructure. Docker Compose, single binary, or Kubernetes — your workspace data stays on servers you control.", }, { title: "No vendor lock-in", @@ -192,13 +196,18 @@ export function createEnDict(allowSignup: boolean): LandingDict { { question: "What coding agents does Multica support?", answer: - "Multica supports 26 coding tools out of the box: Antigravity, Claude Code, CodeBuddy, CodeArts, Codex, Copilot, Cursor, DeepSeek Harness, DevEco Code, Dim, Grok, Hermes, Kimi, Kiro CLI, MiniMax Code, Oh-My-Pi, OpenClaw, OpenCode, Pi, Qoder, Qoder CN, Qwen Code, QwenPaw, Reasonix, Trae CLI, and ZeroClaw. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since it's open source, you can also add your own backends.", + "Multica supports 26 coding tools out of the box: Antigravity, Claude Code, CodeBuddy, CodeArts, Codex, Copilot, Cursor, DeepSeek Harness, DevEco Code, Dim, Grok, Hermes, Kimi, Kiro CLI, MiniMax Code, Oh-My-Pi, OpenClaw, OpenCode, Pi, Qoder, Qoder CN, Qwen Code, QwenPaw, Reasonix, Trae CLI, and ZeroClaw. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since the source code is public, you can also add your own backends.", }, { question: "Do I need to self-host, or is there a cloud version?", answer: "Both. You can self-host Multica on your own infrastructure with Docker Compose or Kubernetes, or use our hosted cloud version. Your data, your choice.", }, + { + question: "Can I use Multica commercially?", + answer: + "Yes. Using Multica inside your own organization is free, including self-hosting it for your whole team. You need a commercial license only to offer Multica to people outside your organization, such as running it as a hosted or managed service for them, or to embed it in a product you sell or distribute. The [licensing FAQ](/licensing) walks through common scenarios.", + }, { question: "How is this different from just using coding agents directly?", @@ -213,19 +222,19 @@ export function createEnDict(allowSignup: boolean): LandingDict { { question: "Is my code safe? Where does agent execution happen?", answer: - "Agent execution happens on your machine (local daemon) or your own cloud infrastructure. Code never passes through Multica servers. The platform only coordinates task state and broadcasts events.", + "Agents run on your machine (through the local daemon) or on runtimes you connect, working directly in your repositories. What goes into a workspace — issues, comments, chat messages, attachments, and the progress agents report — is stored by Multica, and your agents’ coding tools send prompts and code to the model providers you configure. To keep workspace data on your own servers, self-host Multica. See the [privacy policy](/privacy) for details.", }, { question: "How many agents can I run?", answer: - "As many as your hardware supports. Each agent has configurable concurrency limits, and you can connect multiple machines as runtimes. There are no artificial caps in the open source version.", + "As many as your hardware supports. Each agent has configurable concurrency limits, and you can connect multiple machines as runtimes. There are no artificial caps when you self-host.", }, ], }, footer: { tagline: - "Project management for human + agent teams. Open source, self-hostable, built for the future of work.", + "Project management for human + agent teams. Source-available, self-hostable, built for the future of work.", cta: "Get started", groups: { product: { @@ -241,7 +250,7 @@ export function createEnDict(allowSignup: boolean): LandingDict { resources: { label: "Resources", links: [ - { label: "Documentation", href: "/docs" }, + { label: "Documentation", href: docsHref }, { label: "API", href: githubUrl }, { label: "X (Twitter)", href: "https://x.com/MulticaAI" }, { label: "Discord", href: discordUrl }, @@ -251,7 +260,8 @@ export function createEnDict(allowSignup: boolean): LandingDict { label: "Company", links: [ { label: "About", href: "/about" }, - { label: "Open Source", href: "#open-source" }, + { label: "Licensing", href: "/licensing" }, + { label: "Privacy", href: "/privacy" }, { label: "Contact Sales", href: "/contact-sales" }, { label: "GitHub", href: githubUrl }, ], @@ -278,9 +288,228 @@ export function createEnDict(allowSignup: boolean): LandingDict { "We think the same inflection is happening again. For decades, software teams have been single-threaded \u2014 one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the \u201cusers\u201d multiplexing the system are both humans and autonomous agents.", "In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code \u2014 just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.", "Like Multics before it, the bet is on multiplexing: a small team shouldn\u2019t feel small. With the right system, two engineers and a fleet of agents can move like twenty.", - "The platform is fully open source and self-hostable. Your data stays on your infrastructure. Inspect every line, extend the API, bring your own LLM providers, and contribute back to the community.", + "The source code is public and you can self-host Multica for free, keeping your workspace data on your own infrastructure. Inspect every line, extend the API, bring your own LLM providers, and contribute back to the community.", ], cta: "View on GitHub", + team: { + title: "Who\u2019s behind Multica", + paragraphs: [ + "Multica is built by a small team that has been working together since 2021. Before Multica, we built devv.ai, an AI search engine for developers. In 2025 we turned to the problem we kept running into ourselves: how a small team actually gets work done alongside AI agents. That became Multica.", + "The source code is public and you can self-host it, so you can read every line before you build on Multica, and a self-hosted deployment runs entirely on your own infrastructure. How commercial use works is spelled out on our [licensing page](/licensing).", + ], + contacts: [ + { + label: "Commercial licensing & sales", + linkLabel: "Contact Sales", + href: "/contact-sales", + }, + { + label: "How licensing works", + linkLabel: "Licensing FAQ", + href: "/licensing", + }, + { label: "Community & support", linkLabel: "Discord", href: discordUrl }, + { label: "Source code & issues", linkLabel: "GitHub", href: githubUrl }, + ], + }, + }, + + licensing: { + title: "Licensing", + intro: [ + "Multica is released under the [Multica License](https://github.com/multica-ai/multica/blob/main/LICENSE): the Apache License 2.0 with a few additional conditions. The source code is public, and using Multica inside your own organization is free, including self-hosting it for your whole team.", + "The main additional condition covers hosted use: offering Multica to people outside your organization requires a commercial license. This page shows where that line falls, using the questions we hear most often. It is a plain-language guide, not legal advice. If anything here differs from the LICENSE, the LICENSE controls.", + ], + rule: { + title: "The rule of thumb", + text: "Is anyone outside your organization driving the instance — creating issues, talking to agents, or triggering work? If so, through any interface (web, Slack, or API), that is a hosted service. If they only receive results your team produced with Multica, that is internal use.", + }, + scenarios: { + title: "Common scenarios", + scenarioColumn: "Scenario", + licenseColumn: "Commercial license", + required: "Required", + notRequired: "Not required", + items: [ + { + scenario: "Your organization uses Multica internally", + example: "Self-hosted, across any number of workspaces.", + required: false, + }, + { + scenario: + "You deploy Multica for a client, who owns it and uses it internally", + example: "Implementation, training, consulting, or customization work.", + required: false, + }, + { + scenario: + "Your team uses Multica to do work for clients, who only receive the deliverables", + example: + "An agency that runs its content production in Multica and ships the finished work.", + required: false, + }, + { + scenario: + "Agents only push reports or notifications to a client\u2019s Slack channel", + example: + "The client reads them but never interacts with the instance.", + required: false, + }, + { + scenario: + "You run and manage Multica instances for clients on your own infrastructure", + example: "A managed service, whether or not you charge for it.", + required: true, + }, + { + scenario: "People outside your organization sign in to your instance", + example: "Clients, partners, or the public get their own accounts.", + required: true, + }, + { + scenario: + "People outside your organization drive your instance through another entry point", + example: + "A public website backed by Multica, a Slack integration, or an API \u2014 even when it is free.", + required: true, + }, + { + scenario: "You embed Multica in a product you sell or distribute", + example: "Multica ships as a component of another commercial offering.", + required: true, + }, + ], + }, + sections: [ + { + heading: "Other conditions", + bullets: [ + "Branding: keep the Multica logo, product name, and the copyright and attribution information shown in the Multica interface, unless we have given you a written branding waiver.", + "Attribution: if you build on Multica\u2019s backend, daemon, or CLI without the Multica interface, keep the copyright and NOTICE information, and state in your user-facing documentation that your product is built on Multica, with a link to the [GitHub repository](https://github.com/multica-ai/multica).", + "Forks: publishing the source code of a fork is not a hosted service and needs no commercial license. Anyone who operates a hosted service from that fork needs their own.", + "A commercial license and a branding waiver are separate grants. One does not include the other.", + ], + }, + { + heading: "Getting a commercial license", + paragraphs: [ + "Tell us about your use case through [Contact Sales](/contact-sales) and we\u2019ll get back to you within three business days. Not sure whether your setup needs a license? Ask us on [Discord](" + discordUrl + ") or through the same form.", + ], + }, + ], + }, + + privacy: { + title: "Privacy Policy", + lastUpdated: "Last updated: September 24, 2026", + intro: [ + "This Privacy Policy explains how Index Labs (Hong Kong) Limited (“Multica”, “we”, “us”) collects, uses, and shares personal information when you visit multica.ai, contact us, or use Multica Cloud, our hosted service, including the web, desktop, and mobile apps.", + "It does not cover Multica deployments you host yourself. The operator of a self-hosted deployment controls its data, and any AI providers, integrations, or analytics it uses depend on how they configure it. The only thing a self-hosted server sends us is a daily usage snapshot: a random ID for the deployment, so snapshots from the same server can be linked; the server version; approximate counts of workspaces, members, agents, and connected daemons; and the number of agent runs started, completed, failed, and cancelled that day. It contains no names, email addresses, or content. Setting DO_NOT_TRACK=1 turns off this snapshot.", + ], + sections: [ + { + heading: "Information we collect", + bullets: [ + "Account information: your name, email address, and profile picture. If you sign in with Google, we receive your name, email address, and profile picture from Google. You can also add profile details such as language, time zone, and a short bio, and answer onboarding questions such as your role, your use case, and how you heard about Multica.", + "Content you create: workspaces, issues, comments, chat messages, attachments, agent instructions, and anything else you or your agents put into Multica Cloud.", + "Contact Sales inquiries: your name, business email, company name and size, country or region, use case, goals, and communication preferences. To prevent abuse, we also record the IP address and browser user agent the form was sent from.", + "Billing information: subscription payments are handled by Stripe on pages hosted by Stripe. We never receive or store your full card details.", + "Usage and device information: app version, operating system, client type, and a randomly generated installation ID; the name of each machine you connect as a runtime (its hostname by default); and crash and error reports. Before a report is sent, we filter recognizable email addresses and credentials out of the error message, but reports can still contain other details about what went wrong.", + "Feedback: when you send feedback, we receive your message along with the page, app version, operating system, and any error details.", + ], + }, + { + heading: "How we use information", + bullets: [ + "To provide, operate, and secure Multica Cloud, including signing you in, syncing your workspaces, and delivering notifications and invitations.", + "To respond to Contact Sales inquiries and support requests.", + "To send service messages such as sign-in codes and workspace invitations. We only send product updates or marketing if you opted in, and you can unsubscribe at any time.", + "To understand how Multica is used, fix bugs, and improve the product.", + "To prevent abuse and meet our legal obligations.", + ], + }, + { + heading: "Legal bases", + paragraphs: [ + "Where the law requires a legal basis for processing, we rely on performing our contract with you, to provide Multica Cloud; our legitimate interests in securing, supporting, and improving Multica and responding to inquiries; your consent, for marketing messages; and compliance with our legal obligations.", + ], + }, + { + heading: "AI features", + paragraphs: [ + "Your coding agents run on your own machines or on runtimes you connect, using the coding tools and accounts you set up. An agent running on your machine does not mean the model runs there: those tools send prompts, code, files, and tool results to their model providers, under the terms of the tool and account you use. Multica coordinates their work.", + "Some Multica Cloud features, such as chat titles and suggested follow-ups, send your first chat message or a few recent messages to a third-party large language model provider we choose, to generate the result. Multica does not use your content to train AI models.", + ], + }, + { + heading: "Cookies and analytics", + paragraphs: [ + "We use cookies that are needed to keep you signed in, protect against cross-site request forgery, and give you access to files you uploaded. We also use a cookie that remembers which campaign or website referred you, for up to 30 days, and cookies that remember your language and the last workspace you opened.", + "We use PostHog to understand product usage and to collect crash reports. When you are signed in, PostHog receives your account name and email so we can match reports to your account. We do not use advertising cookies, and we do not sell your personal information.", + ], + }, + { + heading: "Who we share information with", + paragraphs: [ + "Information you put into a workspace is visible to its other members and admins, and to the agents and integrations they authorize, according to the workspace’s permissions. If your workspace belongs to an organization, that organization manages its content and may handle requests about it.", + "We also disclose information when the law requires it, and to a buyer or successor if Multica is involved in a merger, acquisition, or sale of assets.", + "Beyond that, we share personal information only with the service providers that help us run Multica and with integrations you choose to connect:", + ], + bullets: [ + "Amazon Web Services: hosting, file storage, and content delivery", + "Vercel: hosting for the website and web app", + "Stripe: payments and billing", + "Resend: sign-in and invitation emails", + "PostHog: product analytics and crash reports", + "Google: sign-in, if you choose Sign in with Google", + "Large language model providers: the AI features described above", + "Integrations you connect, such as Slack, Lark, DingTalk, WeCom, Telegram, GitHub, GitLab, or apps connected through Composio: the data you choose to exchange with them, which is also subject to their own terms", + ], + }, + { + heading: "Where information is stored", + paragraphs: [ + "Multica Cloud is hosted on Amazon Web Services and Vercel. We and our service providers may process your information in the United States and other countries. Wherever it is processed, we protect it as described in this policy.", + ], + }, + { + heading: "How long we keep information", + paragraphs: [ + "We keep account information and workspace content for as long as your account or workspace exists. When a workspace owner deletes a workspace, its issues, comments, and other content are removed from Multica Cloud, though backups we keep for recovery may still contain copies for a period afterwards. To have files uploaded to a deleted workspace erased from our file storage, email [support@multica.ai](mailto:support@multica.ai). We keep billing records for as long as accounting and tax rules require, and product analytics, crash reports, Contact Sales inquiries, and feedback for as long as they are useful for supporting you and improving Multica. We delete inquiries and feedback on request.", + ], + }, + { + heading: "Your choices and rights", + paragraphs: [ + "Depending on where you live, you may have the right to access, correct, delete, or export your personal information; to object to or restrict certain processing; to withdraw consent you have given, such as for marketing messages; and to complain to your local data protection authority. You can update your profile in Multica at any time and delete a workspace you own from its settings. For anything else, including deleting your account, email [support@multica.ai](mailto:support@multica.ai). We will respond within 30 days.", + ], + }, + { + heading: "Security", + paragraphs: [ + "We protect your information with encryption in transit, access controls, and encrypted storage for integration credentials. No system is perfectly secure, so please contact us right away if you believe your account has been compromised.", + ], + }, + { + heading: "Children", + paragraphs: [ + "Multica is not directed to children under 16, and we do not knowingly collect their personal information.", + ], + }, + { + heading: "Changes to this policy", + paragraphs: [ + "We may update this policy from time to time. We will post the new version on this page and update the date at the top. If a change is significant, we will let you know before it takes effect.", + ], + }, + { + heading: "Contact us", + paragraphs: [ + "Multica is operated by Index Labs (Hong Kong) Limited, which is responsible for your personal information. For privacy questions or requests, email [support@multica.ai](mailto:support@multica.ai).", + ], + }, + ], }, changelog: { @@ -293,6 +522,113 @@ export function createEnDict(allowSignup: boolean): LandingDict { fixes: "Bug Fixes", }, entries: [ + { + version: "0.5.2", + date: "2026-09-23", + title: "Steering running tasks, duplicate Issue marks, and steadier task runs", + changes: [], + features: [ + "Add new instructions to a Claude Code or Codex task while it is still running.", + "Mark an Issue as a duplicate from the status picker, jump back to the original, and see that link on lists.", + "Set an Issue's custom properties as you create it from the command line.", + "Mention an agent in a Telegram group and it already knows the recent conversation.", + "Install the command line tool on Windows straight from the download page.", + ], + improvements: [ + "Each OpenClaw agent works in the folder you configured for it.", + "Attachments you upload while creating an Issue show up in its description.", + "A Lark bot that stays silent now points you to where delivery is stuck.", + "Scheduled Issue wakeups are shown in your own time zone.", + "Getting to a task's GitHub pull request is faster.", + "The running indicator is smoother and lighter on your machine.", + ], + fixes: [ + "New Codex models show up in the picker as soon as they are out.", + "A command line sign-in that cannot reach the server says so, instead of waiting forever.", + "An invited member can finish signing up on a self-hosted server that restricts signups.", + "A task whose start goes unconfirmed is picked up again instead of stalling.", + "Cancelling a task answers right away, and a reply in a thread reaches the agent that owns it.", + "The mobile app reconnects on its own after the connection drops.", + "Desktop toolbar buttons are spaced correctly again.", + "The Windows installer runs on PowerShell 5.1.", + "Confirmation dialogs in French no longer scroll sideways.", + "An Autopilot keeps a record of the Issues it creates.", + "A guest squad leader wakes up and picks the work up.", + "You can tell where a WeCom reply was lost on its way back.", + ], + }, + { + version: "0.5.1", + date: "2026-09-21", + title: "Issue wakeup rules, comment permalinks, project starting branches, and steadier channels and runtimes", + changes: [], + features: [ + "Set an Issue to wake an agent up again when a comment arrives, or on a schedule you choose.", + "Manage those wakeup rules from the Issue sidebar or from an Autopilot.", + "Choose the branch or commit a project's repository work starts from.", + "Copy a direct link to any comment or reply, and open it with that comment highlighted.", + "A WeCom answer comes back inside the message you asked from.", + "Point a self-hosted Multica at Gitea or a compatible mirror for updates.", + ], + improvements: [ + "A long WeCom answer arrives in full instead of being dropped.", + "Pages open faster, and runtime usage figures fit on a phone screen.", + ], + fixes: [ + "Two tools with the same name running at once no longer mix up their results.", + "OpenCode 2.x runs again, and custom Oh-My-Pi runtimes are recognized and discovered as before.", + "Telegram replies once per message, even after a restart or a retry.", + "Telegram and DingTalk on a self-hosted server accept the secrets you set.", + "Cancelling sub-tasks tells you which stage was affected and how many.", + "Comments keep their order, and an Issue link still resolves after you reopen a view.", + "A local folder resource no longer offers a rename that cannot work.", + "An image pasted into the editor keeps the format it already had.", + "Inbox wording about agent activity matches what actually happened.", + "Tasks on Windows deliver their results without extra steps.", + ], + }, + { + version: "0.5.0", + date: "2026-09-18", + title: "French interface, steadier and leaner agent runs, the full Inbox archive, and longer-lasting sign-ins", + changes: [], + features: [ + "Set the interface language to French, on the web and in the desktop app.", + "Manage skill labels from the command line, and filter the skills page by label.", + "Pick a thinking level for your Oh-My-Pi agents.", + "Edit a comment you already posted from the command line, without overwriting someone else's edit.", + "Narrow an Issue list down by the status of the project it belongs to.", + "Edit or pause any one of an Autopilot's schedules, instead of deleting it and starting over.", + ], + improvements: [ + "An agent picking a task back up gets straight to work, instead of re-reading the whole Issue and every comment.", + "Staying active keeps you signed in, instead of logging you out every 30 days.", + "The Inbox archive scrolls all the way back, and filters and links reach every notification.", + "Repetitive on-screen explanations are gone, and the Chat list opens at the same width as the Inbox.", + ], + fixes: [ + "WeCom no longer drops messages when several go out at once.", + "Switching an agent's WeCom bot leaves nothing from the old one behind.", + "A message quoted in a WeCom or DingTalk group reaches the agent with your request.", + "A DingTalk reply names the agent answering from its first message.", + "A chat integration that was revoked now shows as disconnected.", + "Runs on Grok, Pi, Copilot, and Codex no longer fail quietly or leave part of the reply out.", + "A Cursor session survives a connection timeout, so you can carry on with it.", + "An outdated OpenCode can no longer fill up your disk.", + "A Hermes task no longer hangs while wrapping up.", + "The desktop app finds the command line tools you installed, and CodeBuddy replies show in full.", + "Runs on Windows follow the tool paths you set.", + "A private runtime no longer refuses to start over a mismatched owner.", + "Task cost and usage are recorded in full again.", + "A commit made in a task uses that task's own Git identity.", + "A task's final result still reaches you after a reconnect.", + "Cancelling a child task moves the parent's stage along correctly.", + "An invitation completed elsewhere no longer stays pending.", + "The mention picker opens mid-word, and keeps working when nothing matches.", + "Sidebar help about PR linking and @all no longer misleads you.", + "Quick Create keeps exactly what you typed.", + ], + }, { version: "0.4.44", date: "2026-09-15", @@ -3518,6 +3854,9 @@ export function createEnDict(allowSignup: boolean): LandingDict { title: "Prefer the CLI?", sub: "For servers, remote dev boxes, and headless setups. Same daemon as Desktop, installed via terminal.", installLabel: "Install", + platformGroup: "Choose your platform", + platformMacosLinux: "macOS / Linux", + platformWindows: "Windows", startLabel: "Start daemon", sshNote: "Already on a server? Same commands work over SSH.", copyLabel: "Copy", @@ -3615,17 +3954,17 @@ export function createEnDict(allowSignup: boolean): LandingDict { ], consent: { intro: - "Multica, Inc. respects your privacy. We’ll use your personal information only to manage your account and deliver the products or services you’ve requested. Occasionally, we’d love to share product updates, best practices, and insights that may be relevant to you. Please let us know below if you’d like to hear from us.", + "Multica respects your privacy. We’ll use your personal information only to manage your account and deliver the products or services you’ve requested. Occasionally, we’d love to share product updates, best practices, and insights that may be relevant to you. Please let us know below if you’d like to hear from us.", outreach: - "I’d like to receive one-to-one communication from Multica, Inc., including service updates, support inquiries, and business-related follow-ups.", + "I’d like to receive one-to-one communication from Multica, including service updates, support inquiries, and business-related follow-ups.", updates: "I’d like to receive product updates, insights, and event invitations from Multica.", unsubscribe: "You can unsubscribe from our communications at any time. For more details on how we handle your data and privacy rights, please review our", submitConsent: - "By clicking “Submit,” you consent to allow Multica, Inc. to store and process your information for the purpose of delivering the requested content.", + "By clicking “Submit,” you consent to allow Multica to store and process your information for the purpose of delivering the requested content.", privacyLinkLabel: "Privacy Policy.", - privacyLinkHref: "/about", + privacyLinkHref: "/privacy", }, success: { title: "Thanks — we got it.", diff --git a/apps/web/features/landing/i18n/index.ts b/apps/web/features/landing/i18n/index.ts index 9adb90b929b..99145401b3e 100644 --- a/apps/web/features/landing/i18n/index.ts +++ b/apps/web/features/landing/i18n/index.ts @@ -6,4 +6,9 @@ export { localeLabels, toLandingDictionaryLocale, } from "./types"; -export type { LandingDict, LandingDictionaryLocale, Locale } from "./types"; +export type { + DocumentSection, + LandingDict, + LandingDictionaryLocale, + Locale, +} from "./types"; diff --git a/apps/web/features/landing/i18n/ja.ts b/apps/web/features/landing/i18n/ja.ts index dc299c8fe05..cdbc5f72c83 100644 --- a/apps/web/features/landing/i18n/ja.ts +++ b/apps/web/features/landing/i18n/ja.ts @@ -2,8 +2,11 @@ import { githubUrl, discordUrl } from "../components/shared"; import { createEnDict } from "./en"; import type { LandingDict } from "./types"; -export function createJaDict(allowSignup: boolean): LandingDict { - const base = createEnDict(allowSignup); +export function createJaDict( + allowSignup: boolean, + docsHref: string, +): LandingDict { + const base = createEnDict(allowSignup, docsHref); return { ...base, @@ -22,7 +25,7 @@ export function createJaDict(allowSignup: boolean): LandingDict { headlineLine1: "次に採用する10人は、", headlineLine2: "人間ではないかもしれません。", subheading: - "Multica は、コーディングエージェントを本物のチームメンバーに変えるオープンソースプラットフォームです。タスクを割り当て、進捗を追い、ノウハウをスキルとして積み上げる。人間と AI エージェントから成るチームを、ひとつの場所で管理できます。", + "Multica は、コーディングエージェントを本物のチームメンバーに変える、ソースコード公開型のプラットフォームです。タスクを割り当て、進捗を追い、ノウハウをスキルとして積み上げる。人間と AI エージェントから成るチームを、ひとつの場所で管理できます。", cta: "無料トライアルを始める", downloadDesktop: "デスクトップ版をダウンロード", talkToSales: "営業に相談する", @@ -157,17 +160,18 @@ export function createJaDict(allowSignup: boolean): LandingDict { ctaDocs: "ドキュメントを読む", }, openSource: { - label: "オープンソース", - headlineLine1: "すべての人に、", - headlineLine2: "オープンソースを。", + label: "ソースコード公開", + headlineLine1: "すべてのコードを、", + headlineLine2: "あなたの手に。", description: - "Multica は完全なオープンソースです。すべてのコードを確認し、自分の条件でセルフホストし、人間と AI エージェントが協働する未来を、ともに形づくっていけます。", + "Multica のソースコードは公開されています。すべてのコードを確認し、無料でセルフホストし、人間と AI エージェントが協働する未来を、ともに形づくっていけます。Multica をホスティングサービスとして他者に提供する場合は、商用ライセンスが必要です。", cta: "GitHub でスターを付ける", + licensingCta: "ライセンスについて →", highlights: [ { title: "どこでもセルフホスト", description: - "自社のインフラで Multica を運用できます。Docker Compose、単一バイナリ、Kubernetes に対応。データがネットワークの外に出ることはありません。", + "自社のインフラで Multica を運用できます。Docker Compose、単一バイナリ、Kubernetes に対応。ワークスペースのデータは、自分で管理するサーバーに保存されます。", }, { title: "ベンダーロックインなし", @@ -193,13 +197,18 @@ export function createJaDict(allowSignup: boolean): LandingDict { { question: "Multica はどのコーディングエージェントに対応していますか?", answer: - "Multica は、Antigravity、Claude Code、CodeBuddy、CodeArts、Codex、Copilot、Cursor、DeepSeek Harness、DevEco Code、Dim、Grok、Hermes、Kimi、Kiro CLI、MiniMax Code、Oh-My-Pi、OpenClaw、OpenCode、Pi、Qoder、Qoder CN、Qwen Code、QwenPaw、Reasonix、Trae CLI、ZeroClaw の26種類のコーディングツールに標準対応しています。デーモンが、すでにインストール済みの CLI を自動検出し、それぞれをランタイムとして登録します。オープンソースなので、独自のバックエンドを追加することもできます。", + "Multica は、Antigravity、Claude Code、CodeBuddy、CodeArts、Codex、Copilot、Cursor、DeepSeek Harness、DevEco Code、Dim、Grok、Hermes、Kimi、Kiro CLI、MiniMax Code、Oh-My-Pi、OpenClaw、OpenCode、Pi、Qoder、Qoder CN、Qwen Code、QwenPaw、Reasonix、Trae CLI、ZeroClaw の26種類のコーディングツールに標準対応しています。デーモンが、すでにインストール済みの CLI を自動検出し、それぞれをランタイムとして登録します。ソースコードが公開されているので、独自のバックエンドを追加することもできます。", }, { question: "セルフホストが必須ですか、それともクラウド版もありますか?", answer: "どちらも可能です。Docker Compose や Kubernetes で自社インフラにセルフホストすることも、当社がホストするクラウド版を使うこともできます。データをどこに置くかは、あなたの選択次第です。", }, + { + question: "Multica は商用利用できますか?", + answer: + "はい。自社の組織内で Multica を使うのは無料です。チーム全体のためにセルフホストする場合も同様です。商用ライセンスが必要になるのは、ホスティングサービスやマネージドサービスなどとして組織外の人に Multica を提供する場合と、販売・配布する製品に Multica を組み込む場合です。よくあるケースは[ライセンスについて](/licensing)で説明しています。", + }, { question: "コーディングエージェントを直接使うのと、何が違うのですか?", @@ -214,18 +223,18 @@ export function createJaDict(allowSignup: boolean): LandingDict { { question: "コードは安全ですか? エージェントの実行はどこで行われますか?", answer: - "エージェントの実行は、あなたのマシン(ローカルデーモン)、または自社のクラウドインフラ上で行われます。コードが Multica のサーバーを経由することはありません。プラットフォームは作業の状態を調整し、イベントを配信する役割だけを担います。", + "エージェントは、あなたのマシン(ローカルデーモン経由)や接続したランタイム上で動作し、コードリポジトリの中で直接作業します。ワークスペースに入力した内容(タスク、コメント、チャットメッセージ、添付ファイル、エージェントが報告する進捗)は Multica に保存されます。また、エージェントが使うコーディングツールは、プロンプトとコードを設定したモデルプロバイダーに送信します。ワークスペースのデータを自社のサーバーに置きたい場合は、Multica をセルフホストしてください。詳しくは[プライバシーポリシー](/privacy)をご覧ください。", }, { question: "エージェントは何個まで動かせますか?", answer: - "ハードウェアが許す限り、いくつでも動かせます。エージェントごとに同時実行数を設定でき、複数のマシンをランタイムとして接続することもできます。オープンソース版に人為的な上限はありません。", + "ハードウェアが許す限り、いくつでも動かせます。エージェントごとに同時実行数を設定でき、複数のマシンをランタイムとして接続することもできます。セルフホストする場合、人為的な上限はありません。", }, ], }, footer: { tagline: - "人間と AI エージェントが協働するチームのためのプロジェクト管理ツール。オープンソースで、セルフホスト可能。これからの働き方のために作られています。", + "人間と AI エージェントが協働するチームのためのプロジェクト管理ツール。ソースコード公開で、セルフホスト可能。これからの働き方のために作られています。", cta: "始める", groups: { product: { @@ -241,7 +250,7 @@ export function createJaDict(allowSignup: boolean): LandingDict { resources: { label: "リソース", links: [ - { label: "ドキュメント", href: "/docs/ja" }, + { label: "ドキュメント", href: docsHref }, { label: "API", href: githubUrl }, { label: "X (Twitter)", href: "https://x.com/MulticaAI" }, { label: "Discord", href: discordUrl }, @@ -251,7 +260,8 @@ export function createJaDict(allowSignup: boolean): LandingDict { label: "会社", links: [ { label: "概要", href: "/about" }, - { label: "オープンソース", href: "#open-source" }, + { label: "ライセンス", href: "/licensing" }, + { label: "プライバシー", href: "/privacy" }, { label: "営業に相談", href: "/contact-sales" }, { label: "GitHub", href: githubUrl }, ], @@ -269,6 +279,113 @@ export function createJaDict(allowSignup: boolean): LandingDict { fixes: "バグ修正", }, entries: [ + { + version: "0.5.2", + date: "2026-09-23", + title: "実行中タスクへの追加指示、Issue の重複マーク、より安定したタスク実行", + changes: [], + features: [ + "Claude Code や Codex のタスクに、実行中でも新しい指示を足せます。", + "ステータスの選択から Issue を重複としてマークし、元の Issue に戻れて、一覧でもその関係が見えます。", + "コマンドラインで Issue を作るときに、カスタムプロパティも一緒に設定できます。", + "Telegram のグループでエージェントに @ を付けると、最近のやり取りを踏まえて答えます。", + "ダウンロードページから Windows 向けのコマンドラインの入れ方が分かります。", + ], + improvements: [ + "OpenClaw のエージェントは、それぞれに設定したフォルダーで作業します。", + "Issue を作るときにアップロードした添付が、説明の中に出ます。", + "返事のない Lark ボットで、配信がどこで止まっているか分かります。", + "Issue の予約された再開が、自分のタイムゾーンで表示されます。", + "タスクの GitHub プルリクエストに、より速くたどり着けます。", + "実行中の表示がなめらかになり、端末の負荷も軽くなります。", + ], + fixes: [ + "Codex の新しいモデルが、出たらすぐ選択肢に並びます。", + "コマンドラインのログインでサーバーに届かないとき、待ち続けずに知らせます。", + "招待されたメンバーは、登録を制限したセルフホストでも登録を完了できます。", + "開始が確認できなかったタスクは、止まったままにならず再び起動されます。", + "タスクの取り消しがすぐ返り、スレッドの返信も担当するエージェントに届きます。", + "モバイルアプリが、接続が切れても自分でつなぎ直します。", + "デスクトップのツールバーの間隔が元に戻ります。", + "Windows のインストーラーが PowerShell 5.1 で動きます。", + "フランス語の確認ダイアログが横にスクロールしなくなります。", + "オートパイロットが作成した Issue が、活動として残ります。", + "ゲストのスクワッドリーダーも正しく再開して作業を引き継ぎます。", + "WeCom の返信が戻らなかったとき、どこで失われたか分かります。", + ], + }, + { + version: "0.5.1", + date: "2026-09-21", + title: "Issue の自動再開、コメントの直接リンク、リポジトリの開始ブランチ、チャネルとランタイムの安定化", + changes: [], + features: [ + "Issue に、新しいコメントが来たときや決めた時刻にエージェントを再開させる設定ができます。", + "その再開ルールは Issue のサイドバーやオートパイロットから管理できます。", + "プロジェクトのリポジトリ作業を、どのブランチやコミットから始めるか指定できます。", + "コメントも返信も直接のリンクをコピーでき、開くとそのコメントが強調表示されます。", + "WeCom の回答が、質問したメッセージの中に返ってきます。", + "セルフホストの更新の取得先に Gitea や互換のミラーを使えます。", + ], + improvements: [ + "WeCom の長い回答が、途中で失われず全部届きます。", + "ページの表示が速くなり、ランタイムの使用量がスマートフォンの画面にも収まります。", + ], + fixes: [ + "同じ名前のツールを同時に実行しても、結果が入れ替わりません。", + "OpenCode 2.x が動き、Oh-My-Pi のカスタムランタイムも正しく認識・検出されます。", + "Telegram の返信は 1 回だけで、再起動や再試行のあとも重複しません。", + "セルフホストの Telegram と DingTalk が、設定したシークレットを正しく受け取ります。", + "サブタスクを取り消すと、どのステージがいくつ影響を受けたか分かります。", + "コメントの並び順が安定し、画面を開き直しても Issue のリンクが開けます。", + "ローカル フォルダーのリソースに、使えない名前の変更が出なくなります。", + "エディターに貼り付けた画像が、元の形式を保ちます。", + "Inbox のエージェントの活動の文言が、実際の内容と合います。", + "Windows のタスクが、余分な手順なしに結果を届けます。", + ], + }, + { + version: "0.5.0", + date: "2026-09-18", + title: "フランス語の画面、より安定して無駄のないエージェント実行、Inbox のアーカイブ全件、より長く続くログイン", + changes: [], + features: [ + "画面の言語にフランス語を選べます。Web でもデスクトップアプリでも使えます。", + "コマンドラインでスキルにラベルを付けられ、スキルのページでラベルで絞り込めます。", + "Oh-My-Pi のエージェントに思考レベルを設定できます。", + "コマンドラインで投稿済みのコメントを編集でき、他の人の同時の編集を上書きしません。", + "Issue 一覧を、所属するプロジェクトのステータスで絞り込めます。", + "オートパイロットのスケジュールを 1 つずつ編集・一時停止でき、消して作り直す必要がありません。", + ], + improvements: [ + "エージェントが作業を再開するとき、Issue とコメントを最初から読み直しません。", + "使い続けている間はログインが延長され、30 日ごとに強制的にログアウトされません。", + "Inbox のアーカイブを一番古い通知までたどれ、絞り込みもリンクも全体に届きます。", + "重複した説明文がなくなり、Chat の一覧の初期の幅が Inbox と揃いました。", + ], + fixes: [ + "WeCom で続けて送っても、メッセージが落ちません。", + "エージェントの WeCom ボットを入れ替えると、前のボットの情報が残りません。", + "WeCom と DingTalk のグループで引用したメッセージも、依頼と一緒にエージェントへ届きます。", + "DingTalk の返信は、最初のメッセージからどのエージェントが答えているか分かります。", + "取り消された連携は、切断済みとして表示されます。", + "Grok、Pi、Copilot、Codex の実行が、黙って失敗したり返答の一部を落としたりしません。", + "Cursor のセッションは接続タイムアウトのあとも残り、そのまま続けられます。", + "古い OpenCode がディスクを埋め尽くすことはありません。", + "Hermes のタスクが終了処理で止まりません。", + "デスクトップアプリが自分で入れたコマンドラインツールを見つけ、CodeBuddy の返答も全部表示されます。", + "Windows の実行が、自分で設定したツールのパスを使います。", + "プライベートランタイムが、所有者の食い違いで使えなくなりません。", + "タスクの費用と使用量が漏れずに記録されます。", + "タスク内のコミットが、そのタスク自身の Git の情報を使います。", + "再接続したあとも、タスクの最終結果が届きます。", + "子タスクを取り消したとき、親のステージの進み方が正しくなります。", + "別の場所で完了した招待が、保留のまま残りません。", + "メンションの候補が語の途中でも開き、該当なしのときも操作できます。", + "サイドバーの PR 連携と @all の説明が、誤解を招きません。", + "Quick Create が入力した内容をそのまま残します。", + ], + }, { version: "0.4.44", date: "2026-09-15", @@ -2958,9 +3075,211 @@ export function createJaDict(allowSignup: boolean): LandingDict { "いま、同じような転換点がふたたび訪れていると私たちは考えています。この数十年、ソフトウェアチームは事実上シングルスレッドで働いてきました。一人のエンジニアが、ひとつのタスクを担い、一度にひとつの文脈だけを切り替えながら進める。AI エージェントは、その方程式を変えます。Multica はタイムシェアリングをふたたび呼び戻します。ただし今回は、システムを多重利用する「ユーザー」が、人間と自律的なエージェントの両方である時代に合わせて。", "Multica では、エージェントは一級のチームメンバーです。人間の同僚とまったく同じように、タスクを割り当てられ、進捗を報告し、行き詰まりを知らせ、コードをリリースします。担当者の選択、アクティビティタイムライン、作業のライフサイクル、そしてランタイムのインフラは、すべてこの前提を中心に、最初から設計されています。", "かつての Multics と同じく、賭けの中心にあるのは多重化(multiplexing)です。小さなチームが、小さく動く必要はありません。適切なシステムがあれば、二人のエンジニアと一群のエージェントは、二十人のように動けます。", - "プラットフォームは完全なオープンソースで、セルフホスト可能です。データはあなたのインフラに残ります。すべてのコードを確認し、API を拡張し、好きな LLM プロバイダーを持ち込み、コミュニティに貢献できます。", + "ソースコードは公開されており、無料でセルフホストできます。ワークスペースのデータは、あなた自身のインフラに保存されます。すべてのコードを確認し、API を拡張し、好きな LLM プロバイダーを持ち込み、コミュニティに貢献できます。", ], cta: "GitHub で見る", + team: { + title: "Multica をつくっているチーム", + paragraphs: [ + "Multica は、2021年から一緒に働いてきた小さなチームがつくっています。Multica の前は、開発者向けの AI 検索エンジン devv.ai を開発していました。2025年、私たち自身が何度もぶつかってきた課題、つまり小さなチームが AI エージェントと一緒に実際に仕事を進めるにはどうすればいいのか、に取り組み始めました。それが Multica です。", + "ソースコードは公開されていてセルフホストもできるので、Multica の上に何かを築く前に、すべてのコードを確認できます。セルフホストした環境は、すべてあなた自身のインフラ上で動きます。商用利用のルールは[ライセンスについて](/licensing)のページで説明しています。", + ], + contacts: [ + { label: "商用ライセンス・営業", linkLabel: "営業に相談", href: "/contact-sales" }, + { label: "ライセンスの考え方", linkLabel: "ライセンス FAQ", href: "/licensing" }, + { label: "コミュニティ・サポート", linkLabel: "Discord", href: discordUrl }, + { label: "ソースコード・Issue", linkLabel: "GitHub", href: githubUrl }, + ], + }, + }, + licensing: { + title: "ライセンス", + intro: [ + "Multica は [Multica License](https://github.com/multica-ai/multica/blob/main/LICENSE) のもとで公開されています。Apache License 2.0 に、いくつかの追加条件を加えたライセンスです。ソースコードは公開されており、自社の組織内で Multica を使うのは無料です。チーム全体のためにセルフホストする場合も同様です。", + "主な追加条件は、ホスティングでの利用に関するものです。組織外の人に Multica を提供するには、商用ライセンスが必要です。このページでは、よくいただく質問をもとに、その線引きを説明します。これはわかりやすさを優先した案内であり、法的助言ではありません。LICENSE 本文と内容が異なる場合は、LICENSE が優先されます。", + ], + rule: { + title: "判断の目安", + text: "組織外の誰かが、このインスタンスを動かしているかどうか。つまり、タスクを作成したり、エージェントと対話したり、作業をトリガーしたりしているかどうかです。そうであれば、Web、Slack、API のどの経路でも、ホスティングサービスにあたります。あなたのチームが Multica でつくった成果物を受け取るだけなら、社内利用です。", + }, + scenarios: { + title: "よくあるケース", + scenarioColumn: "ケース", + licenseColumn: "商用ライセンス", + required: "必要", + notRequired: "不要", + items: [ + { + scenario: "自社の組織内で Multica を使う", + example: "セルフホストで、ワークスペースの数は問いません。", + required: false, + }, + { + scenario: "クライアントのために Multica を導入し、クライアント自身が所有して社内で使う", + example: "導入支援、トレーニング、コンサルティング、カスタマイズなど。", + required: false, + }, + { + scenario: "自社チームが Multica を使ってクライアントの仕事をし、クライアントは成果物だけを受け取る", + example: "たとえば、Multica でコンテンツ制作を管理し、完成品を納品するエージェンシー。", + required: false, + }, + { + scenario: "エージェントがクライアントの Slack チャンネルにレポートや通知を一方向に送るだけ", + example: "クライアントは内容を読むだけで、インスタンスとはやり取りしません。", + required: false, + }, + { + scenario: "自社のインフラ上で、クライアントのために Multica インスタンスを運用・管理する", + example: "マネージドサービス。料金を取るかどうかは関係ありません。", + required: true, + }, + { + scenario: "組織外の人があなたのインスタンスにログインする", + example: "クライアント、パートナー、一般ユーザーが自分のアカウントを持つ場合。", + required: true, + }, + { + scenario: "組織外の人が別の入口からあなたのインスタンスを動かす", + example: "Multica をバックエンドにした公開サイト、Slack 連携、API など。無料で提供する場合も同様です。", + required: true, + }, + { + scenario: "販売・配布する製品に Multica を組み込む", + example: "Multica が別の商用製品のコンポーネントとして提供される場合。", + required: true, + }, + ], + }, + sections: [ + { + heading: "そのほかの条件", + bullets: [ + "ブランド: 書面によるブランド表示の免除を受けていない限り、Multica のインターフェースに表示される Multica のロゴ、製品名、著作権・帰属表示を削除・変更しないでください。", + "帰属表示: Multica のインターフェースを使わずに、バックエンド、デーモン、CLI の上に製品を構築する場合は、著作権表示と NOTICE の内容を残し、ユーザー向けドキュメントに Multica をベースにしていることを [GitHub リポジトリ](https://github.com/multica-ai/multica)へのリンクとともに明記してください。", + "フォーク: フォークのソースコードを公開すること自体はホスティングサービスではなく、商用ライセンスは不要です。ただし、そのフォークでホスティングサービスを運営する人は、それぞれ商用ライセンスが必要です。", + "商用ライセンスとブランド表示の免除は別々の許諾です。一方を得ても、もう一方は含まれません。", + ], + }, + { + heading: "商用ライセンスの取得", + paragraphs: [ + "[営業に相談](/contact-sales)からユースケースをお知らせください。3営業日以内にご連絡します。ライセンスが必要かどうかわからない場合は、[Discord](" + discordUrl + ") または同じフォームからお気軽にお尋ねください。", + ], + }, + ], + }, + privacy: { + title: "プライバシーポリシー", + lastUpdated: "最終更新日: 2026年9月24日", + intro: [ + "このプライバシーポリシーは、Index Labs (Hong Kong) Limited(以下「Multica」または「当社」)が、multica.ai へのアクセス、当社へのお問い合わせ、またはホスティングサービスである Multica Cloud(Web、デスクトップ、モバイルアプリを含む)のご利用に際して、個人情報をどのように収集、利用、共有するかを説明するものです。", + "このポリシーは、お客様がご自身でホストする Multica には適用されません。セルフホスト環境のデータは運用者が管理し、どの AI プロバイダー、連携サービス、分析ツールを使うかは運用者の設定によります。セルフホストのサーバーが当社に送信するのは、1日1回の利用状況スナップショットのみです。内容は、同じサーバーのスナップショットを関連付けるためのランダムな環境 ID、サーバーのバージョン、ワークスペース・メンバー・エージェント・接続中のデーモンのおおよその数、その日に開始・完了・失敗・キャンセルされた実行の数です。氏名、メールアドレス、コンテンツは含まれません。DO_NOT_TRACK=1 を設定すると、このスナップショットの送信を停止できます。", + "このポリシーは英語版を正本とします。日本語版と英語版の内容が異なる場合は、英語版が優先されます。", + ], + sections: [ + { + heading: "収集する情報", + bullets: [ + "アカウント情報: 氏名、メールアドレス、プロフィール画像。Google でサインインした場合は、Google から氏名、メールアドレス、プロフィール画像を受け取ります。言語、タイムゾーン、自己紹介などのプロフィール情報や、オンボーディングでの回答(役割、用途、Multica を知ったきっかけなど)を入力いただくこともできます。", + "お客様が作成するコンテンツ: ワークスペース、タスク、コメント、チャットメッセージ、添付ファイル、エージェントへの指示など、お客様またはお客様のエージェントが Multica Cloud に入力するすべてのもの。", + "営業へのお問い合わせ: 氏名、業務用メールアドレス、会社名と規模、国・地域、用途、目標、ご連絡に関するご希望。不正利用を防ぐため、フォーム送信時の IP アドレスとブラウザのユーザーエージェントも記録します。", + "請求情報: サブスクリプションの支払いは、Stripe がホストするページで Stripe が処理します。当社がカード情報の全体を受け取ったり保存したりすることはありません。", + "利用状況とデバイスの情報: アプリのバージョン、OS、クライアントの種類、ランダムに生成されたインストール ID。ランタイムとして接続した各マシンの名前(デフォルトはホスト名)。クラッシュやエラーのレポート。レポートの送信前に、エラーメッセージから識別できるメールアドレスや認証情報を取り除きますが、レポートには不具合の状況に関するその他の情報が含まれることがあります。", + "フィードバック: フィードバックをお送りいただいた場合、その内容と、ページ、アプリのバージョン、OS、エラーの詳細。", + ], + }, + { + heading: "情報の利用目的", + bullets: [ + "Multica Cloud の提供、運用、保護(サインイン、ワークスペースの同期、通知や招待の配信など)。", + "営業へのお問い合わせやサポートへの対応。", + "サインインコードやワークスペースへの招待など、サービスに関するメッセージの送信。製品のお知らせやマーケティング情報は同意いただいた場合にのみお送りし、いつでも配信を停止できます。", + "Multica の利用状況の把握、不具合の修正、製品の改善。", + "不正利用の防止と法的義務の履行。", + ], + }, + { + heading: "法的根拠", + paragraphs: [ + "法令で処理の法的根拠の明示が求められる地域では、次の根拠に基づいて個人情報を処理します。Multica Cloud を提供するためのお客様との契約の履行、Multica の安全確保・サポート・改善およびお問い合わせへの対応に関する当社の正当な利益、マーケティング情報の受信に関するお客様の同意、そして当社の法的義務の遵守です。", + ], + }, + { + heading: "AI 機能", + paragraphs: [ + "コーディングエージェントは、お客様自身のマシンやお客様が接続したランタイム上で、お客様が設定したコーディングツールとアカウントを使って動作します。エージェントがローカルで動いていても、モデルの推論までローカルで行われるわけではありません。これらのツールは、プロンプト、コード、ファイル、ツールの実行結果をそれぞれのモデルプロバイダーに送信し、その扱いはお客様が使うツールとアカウントの規約に従います。Multica はエージェントの作業を調整します。", + "Multica Cloud の一部の機能(チャットのタイトル生成や、次のアクションの提案など)では、結果を生成するために、最初のチャットメッセージや直近のいくつかのメッセージを、当社が選んだ外部の大規模言語モデルプロバイダーに送信します。Multica がお客様のコンテンツを AI モデルの学習に使うことはありません。", + ], + }, + { + heading: "Cookie と分析", + paragraphs: [ + "サインイン状態の維持、クロスサイトリクエストフォージェリの防止、アップロードしたファイルへのアクセスのために、必要な Cookie を使用します。また、どのキャンペーンやサイトから来訪されたかを最長30日間記録する Cookie や、言語と最後に開いたワークスペースを記憶する Cookie も使用します。", + "製品の利用状況の把握とクラッシュレポートの収集には PostHog を使用します。サインイン中は、レポートをアカウントとひも付けるため、アカウントの氏名とメールアドレスが PostHog に送信されます。広告目的の Cookie は使用せず、お客様の個人情報を販売することもありません。", + ], + }, + { + heading: "情報の共有先", + paragraphs: [ + "ワークスペースに入力した情報は、ワークスペースの権限設定に従って、他のメンバーや管理者、そして彼らが許可したエージェントや連携サービスから閲覧できます。ワークスペースが組織に属している場合、その内容は組織が管理し、関連するご依頼にも組織が対応することがあります。", + "法令で求められる場合には情報を開示します。また、Multica が合併、買収、事業譲渡の対象となった場合、情報が買収者または承継者に移転されることがあります。", + "それ以外に当社が個人情報を共有するのは、Multica の運営を支援するサービス提供者と、お客様が接続を選んだ連携サービスに限られます。", + ], + bullets: [ + "Amazon Web Services: ホスティング、ファイルの保存、コンテンツ配信", + "Vercel: Web サイトと Web アプリのホスティング", + "Stripe: 支払いと請求", + "Resend: サインインと招待のメール", + "PostHog: 製品分析とクラッシュレポート", + "Google: Google でのサインインを選んだ場合", + "大規模言語モデルプロバイダー: 上記の AI 機能", + "お客様が接続した連携サービス(Slack、Lark、DingTalk、WeCom、Telegram、GitHub、GitLab、Composio 経由で接続したアプリなど): お客様がそれらを通じて送受信するデータ。各サービスの規約も適用されます", + ], + }, + { + heading: "情報の保存場所", + paragraphs: [ + "Multica Cloud は Amazon Web Services と Vercel 上でホストされています。当社およびサービス提供者は、米国その他の国や地域でお客様の情報を処理することがあります。どこで処理される場合も、本ポリシーに従って情報を保護します。", + ], + }, + { + heading: "保存期間", + paragraphs: [ + "アカウント情報とワークスペースのコンテンツは、アカウントやワークスペースが存在する間保存します。ワークスペースのオーナーがワークスペースを削除すると、そのタスクやコメントなどのコンテンツは Multica Cloud から削除されます。ただし、復旧用のバックアップには、削除後もしばらくの間コピーが残ることがあります。削除したワークスペースにアップロードされたファイルをファイルストレージから消去したい場合は、[support@multica.ai](mailto:support@multica.ai) までご連絡ください。請求記録は会計・税務上必要な期間保存し、製品分析データ、クラッシュレポート、営業へのお問い合わせ、フィードバックは、サポートと製品改善に必要な期間保存します。お問い合わせとフィードバックは、ご依頼があれば削除します。", + ], + }, + { + heading: "お客様の選択と権利", + paragraphs: [ + "お住まいの地域の法令によっては、個人情報へのアクセス、訂正、削除、書き出しを求める権利、特定の処理に異議を唱えたり制限したりする権利、すでに与えた同意(マーケティング情報の受信など)を撤回する権利、そして地域のデータ保護当局に苦情を申し立てる権利がある場合があります。プロフィールは Multica 内でいつでも更新でき、オーナーであるワークスペースは設定から削除できます。アカウントの削除など、そのほかのご依頼は [support@multica.ai](mailto:support@multica.ai) までメールでご連絡ください。30日以内に回答します。", + ], + }, + { + heading: "セキュリティ", + paragraphs: [ + "通信の暗号化、アクセス制御、連携サービスの認証情報の暗号化保存などにより、お客様の情報を保護しています。完全に安全なシステムは存在しないため、アカウントが不正に利用されたと思われる場合は、すぐにご連絡ください。", + ], + }, + { + heading: "お子様について", + paragraphs: [ + "Multica は16歳未満のお子様を対象としておらず、当社がお子様の個人情報を故意に収集することはありません。", + ], + }, + { + heading: "ポリシーの変更", + paragraphs: [ + "本ポリシーは随時更新されることがあります。新しい版はこのページに掲載し、冒頭の日付を更新します。重要な変更がある場合は、効力が生じる前にお知らせします。", + ], + }, + { + heading: "お問い合わせ", + paragraphs: [ + "Multica は Index Labs (Hong Kong) Limited が運営しており、同社がお客様の個人情報について責任を負います。プライバシーに関するご質問やご依頼は、[support@multica.ai](mailto:support@multica.ai) までメールでご連絡ください。", + ], + }, + ], }, download: { hero: { @@ -3019,6 +3338,9 @@ export function createJaDict(allowSignup: boolean): LandingDict { title: "CLI のほうが便利ですか?", sub: "サーバー、リモート開発環境、ヘッドレス環境に最適です。デスクトップと同じデーモンを、ターミナルからインストールできます。", installLabel: "インストール", + platformGroup: "プラットフォームを選択", + platformMacosLinux: "macOS / Linux", + platformWindows: "Windows", startLabel: "デーモンを起動", sshNote: "すでにサーバーに接続中ですか? 同じコマンドが SSH 上でもそのまま使えます。", copyLabel: "コピー", @@ -3116,17 +3438,17 @@ export function createJaDict(allowSignup: boolean): LandingDict { ], consent: { intro: - "Multica, Inc. はあなたのプライバシーを尊重します。お預かりした個人情報は、アカウントの管理と、ご依頼いただいた製品・サービスの提供のためにのみ使用します。ときどき、製品アップデートやベストプラクティス、ご参考になりそうなインサイトもお届けできればと思います。ご希望の場合は、下でお知らせください。", + "Multica はあなたのプライバシーを尊重します。お預かりした個人情報は、アカウントの管理と、ご依頼いただいた製品・サービスの提供のためにのみ使用します。ときどき、製品アップデートやベストプラクティス、ご参考になりそうなインサイトもお届けできればと思います。ご希望の場合は、下でお知らせください。", outreach: - "サービスのアップデート、サポートへのお問い合わせ、ビジネス関連のフォローアップなど、Multica, Inc. からの個別のご連絡を受け取ります。", + "サービスのアップデート、サポートへのお問い合わせ、ビジネス関連のフォローアップなど、Multica からの個別のご連絡を受け取ります。", updates: "Multica からの製品アップデート、インサイト、イベントへのご招待を受け取ります。", unsubscribe: "配信はいつでも停止できます。データの取り扱いとプライバシーの権利について詳しくは、こちらをご確認ください:", submitConsent: - "「送信」をクリックすると、ご依頼いただいたコンテンツをお届けするために、Multica, Inc. が情報を保存・処理することに同意したことになります。", + "「送信」をクリックすると、ご依頼いただいたコンテンツをお届けするために、Multica が情報を保存・処理することに同意したことになります。", privacyLinkLabel: "プライバシーポリシー。", - privacyLinkHref: "/about", + privacyLinkHref: "/privacy", }, success: { title: "ありがとうございます。確かに受け取りました。", diff --git a/apps/web/features/landing/i18n/ko.ts b/apps/web/features/landing/i18n/ko.ts index dfa3294bb0f..1520cde0c14 100644 --- a/apps/web/features/landing/i18n/ko.ts +++ b/apps/web/features/landing/i18n/ko.ts @@ -2,8 +2,11 @@ import { githubUrl, discordUrl } from "../components/shared"; import { createEnDict } from "./en"; import type { LandingDict } from "./types"; -export function createKoDict(allowSignup: boolean): LandingDict { - const base = createEnDict(allowSignup); +export function createKoDict( + allowSignup: boolean, + docsHref: string, +): LandingDict { + const base = createEnDict(allowSignup, docsHref); return { ...base, @@ -22,7 +25,7 @@ export function createKoDict(allowSignup: boolean): LandingDict { headlineLine1: "다음에 합류할 10명은", headlineLine2: "사람이 아닐지도 모릅니다.", subheading: - "Multica는 코딩 AI 에이전트를 진짜 팀원으로 만들어 주는 오픈소스 플랫폼입니다. 태스크를 맡기고, 진행 상황을 따라가고, 반복되는 노하우를 스킬로 쌓아 두세요. 사람과 AI 에이전트로 이루어진 팀을 한곳에서 관리할 수 있습니다.", + "Multica는 코딩 AI 에이전트를 진짜 팀원으로 만들어 주는 소스 공개 플랫폼입니다. 태스크를 맡기고, 진행 상황을 따라가고, 반복되는 노하우를 스킬로 쌓아 두세요. 사람과 AI 에이전트로 이루어진 팀을 한곳에서 관리할 수 있습니다.", cta: "무료로 시작하기", downloadDesktop: "데스크톱 다운로드", talkToSales: "영업팀에 문의", @@ -157,17 +160,18 @@ export function createKoDict(allowSignup: boolean): LandingDict { ctaDocs: "문서 읽기", }, openSource: { - label: "오픈소스", - headlineLine1: "모두를 위한", - headlineLine2: "오픈소스.", + label: "소스 공개", + headlineLine1: "모든 코드를,", + headlineLine2: "여러분의 방식대로.", description: - "Multica는 완전한 오픈소스입니다. 코드를 직접 들여다보고, 원하는 환경에 셀프 호스팅하고, 사람과 AI 에이전트가 함께 일하는 방식을 같이 만들어 갈 수 있습니다.", + "Multica의 소스 코드는 공개되어 있습니다. 코드를 직접 들여다보고, 무료로 셀프 호스팅하고, 사람과 AI 에이전트가 함께 일하는 방식을 같이 만들어 갈 수 있습니다. Multica를 다른 사람에게 호스팅 서비스로 제공하려면 상용 라이선스가 필요합니다.", cta: "GitHub에서 스타 누르기", + licensingCta: "라이선스 안내 →", highlights: [ { title: "어디서든 셀프 호스팅", description: - "자체 인프라에서 Multica를 직접 운영하세요. Docker Compose, 단일 바이너리, Kubernetes를 지원하며, 데이터는 여러분의 네트워크 안에 그대로 남습니다.", + "자체 인프라에서 Multica를 직접 운영하세요. Docker Compose, 단일 바이너리, Kubernetes를 지원하며, 워크스페이스 데이터는 여러분이 관리하는 서버에 저장됩니다.", }, { title: "벤더 종속 없음", @@ -193,13 +197,18 @@ export function createKoDict(allowSignup: boolean): LandingDict { { question: "Multica는 어떤 코딩 에이전트를 지원하나요?", answer: - "Multica는 Antigravity, Claude Code, CodeBuddy, CodeArts, Codex, Copilot, Cursor, DeepSeek Harness, DevEco Code, Dim, Grok, Hermes, Kimi, Kiro CLI, MiniMax Code, Oh-My-Pi, OpenClaw, OpenCode, Pi, Qoder, Qoder CN, Qwen Code, QwenPaw, Reasonix, Trae CLI, ZeroClaw 등 26개 코딩 도구를 기본 지원합니다. 데몬이 이미 설치된 CLI를 자동으로 찾아 각각 런타임으로 등록합니다. 오픈소스이므로 직접 백엔드를 추가할 수도 있습니다.", + "Multica는 Antigravity, Claude Code, CodeBuddy, CodeArts, Codex, Copilot, Cursor, DeepSeek Harness, DevEco Code, Dim, Grok, Hermes, Kimi, Kiro CLI, MiniMax Code, Oh-My-Pi, OpenClaw, OpenCode, Pi, Qoder, Qoder CN, Qwen Code, QwenPaw, Reasonix, Trae CLI, ZeroClaw 등 26개 코딩 도구를 기본 지원합니다. 데몬이 이미 설치된 CLI를 자동으로 찾아 각각 런타임으로 등록합니다. 소스 코드가 공개되어 있으므로 직접 백엔드를 추가할 수도 있습니다.", }, { question: "셀프 호스팅만 가능한가요, 클라우드 버전도 있나요?", answer: "둘 다 가능합니다. Docker Compose나 Kubernetes로 자체 인프라에 직접 호스팅할 수도 있고, Multica가 운영하는 클라우드 버전을 그대로 쓸 수도 있습니다. 데이터를 어디에 둘지는 직접 선택할 수 있습니다.", }, + { + question: "Multica를 상업적으로 사용할 수 있나요?", + answer: + "네. 조직 내부에서 Multica를 사용하는 것은 무료이며, 팀 전체를 위해 셀프 호스팅하는 경우도 마찬가지입니다. 상용 라이선스는 호스팅 서비스나 매니지드 서비스처럼 조직 외부 사람들에게 Multica를 제공하거나, 판매·배포하는 제품에 Multica를 포함할 때만 필요합니다. 자주 묻는 사례는 [라이선스 안내](/licensing)에서 확인하세요.", + }, { question: "코딩 에이전트를 직접 쓰는 것과 무엇이 다른가요?", answer: @@ -213,18 +222,18 @@ export function createKoDict(allowSignup: boolean): LandingDict { { question: "코드는 안전한가요? 에이전트는 어디서 실행되나요?", answer: - "에이전트 실행은 사용자의 컴퓨터에 있는 로컬 데몬, 또는 직접 운영하는 클라우드 인프라에서 이뤄집니다. 코드는 Multica 서버를 거치지 않습니다. Multica는 작업 상태를 조율하고 이벤트를 전달하는 역할만 합니다.", + "에이전트는 여러분의 컴퓨터(로컬 데몬)나 연결한 런타임에서 실행되며, 코드 저장소 안에서 바로 작업합니다. 워크스페이스에 입력한 내용(태스크, 댓글, 채팅 메시지, 첨부 파일, 에이전트가 보고하는 진행 상황)은 Multica에 저장되고, 에이전트가 쓰는 코딩 도구는 프롬프트와 코드를 여러분이 설정한 모델 제공자에게 보냅니다. 워크스페이스 데이터를 자체 서버에 두고 싶다면 Multica를 셀프 호스팅하세요. 자세한 내용은 [개인정보 처리방침](/privacy)을 참고하세요.", }, { question: "에이전트는 몇 개까지 실행할 수 있나요?", answer: - "하드웨어가 감당할 수 있는 만큼 실행할 수 있습니다. 에이전트마다 동시 실행 수를 조절할 수 있고, 여러 대의 머신을 런타임으로 함께 연결할 수도 있습니다. 오픈소스 버전에는 별도의 제한이 없습니다.", + "하드웨어가 감당할 수 있는 만큼 실행할 수 있습니다. 에이전트마다 동시 실행 수를 조절할 수 있고, 여러 대의 머신을 런타임으로 함께 연결할 수도 있습니다. 셀프 호스팅할 때는 별도의 제한이 없습니다.", }, ], }, footer: { tagline: - "사람과 AI 에이전트가 함께 일하는 팀을 위한 프로젝트 관리 도구. 오픈소스이며, 원하는 곳에 직접 호스팅할 수 있습니다.", + "사람과 AI 에이전트가 함께 일하는 팀을 위한 프로젝트 관리 도구. 소스 코드가 공개되어 있으며, 원하는 곳에 직접 호스팅할 수 있습니다.", cta: "시작하기", groups: { product: { @@ -240,7 +249,7 @@ export function createKoDict(allowSignup: boolean): LandingDict { resources: { label: "리소스", links: [ - { label: "문서", href: "/docs/ko" }, + { label: "문서", href: docsHref }, { label: "API", href: githubUrl }, { label: "X (Twitter)", href: "https://x.com/MulticaAI" }, { label: "Discord", href: discordUrl }, @@ -250,7 +259,8 @@ export function createKoDict(allowSignup: boolean): LandingDict { label: "회사", links: [ { label: "소개", href: "/about" }, - { label: "오픈소스", href: "#open-source" }, + { label: "라이선스", href: "/licensing" }, + { label: "개인정보 처리방침", href: "/privacy" }, { label: "영업팀 문의", href: "/contact-sales" }, { label: "GitHub", href: githubUrl }, ], @@ -268,6 +278,113 @@ export function createKoDict(allowSignup: boolean): LandingDict { fixes: "버그 수정", }, entries: [ + { + version: "0.5.2", + date: "2026-09-23", + title: "실행 중 작업에 지시 추가, Issue 중복 표시, 더 안정적인 작업 실행", + changes: [], + features: [ + "Claude Code나 Codex 작업이 실행 중일 때도 새 지시를 더할 수 있습니다.", + "상태 선택에서 Issue를 중복으로 표시하고, 원래 Issue로 바로 이동하며, 목록에서도 그 관계가 보입니다.", + "커맨드라인에서 Issue를 만들 때 사용자 지정 속성도 함께 설정할 수 있습니다.", + "Telegram 그룹에서 에이전트를 @하면 최근 대화를 이미 알고 답합니다.", + "다운로드 페이지에서 Windows용 커맨드라인 설치 방법을 바로 볼 수 있습니다.", + ], + improvements: [ + "OpenClaw의 각 에이전트가 자신에게 설정된 폴더에서 작업합니다.", + "Issue를 만들 때 올린 첨부 파일이 설명에 나타납니다.", + "답이 없는 Lark 봇에서 전달이 어디서 막혔는지 알 수 있습니다.", + "Issue의 예약된 재개가 자신의 시간대로 표시됩니다.", + "작업의 GitHub 풀 리퀘스트로 더 빨리 이동합니다.", + "실행 중 표시가 더 부드러워지고 기기 부담도 줄었습니다.", + ], + fixes: [ + "Codex의 새 모델이 나오는 대로 선택 목록에 뜹니다.", + "커맨드라인 로그인이 서버에 닿지 않으면 계속 기다리지 않고 알려 줍니다.", + "초대받은 멤버는 가입을 제한한 셀프 호스팅에서도 가입을 마칠 수 있습니다.", + "시작이 확인되지 않은 작업은 멈춰 있지 않고 다시 시작됩니다.", + "작업 취소가 바로 응답하고, 스레드의 답글도 담당 에이전트에게 갑니다.", + "모바일 앱이 연결이 끊겨도 스스로 다시 연결합니다.", + "데스크톱 툴바 버튼 간격이 원래대로 돌아왔습니다.", + "Windows 설치 스크립트가 PowerShell 5.1에서도 실행됩니다.", + "프랑스어 확인 창이 옆으로 스크롤되지 않습니다.", + "오토파일럿이 만든 Issue가 활동으로 기록됩니다.", + "게스트 스쿼드 리더도 정상적으로 깨어나 일을 이어받습니다.", + "WeCom 답변이 돌아오지 않았을 때 어디서 사라졌는지 알 수 있습니다.", + ], + }, + { + version: "0.5.1", + date: "2026-09-21", + title: "Issue 자동 재개, 댓글 직접 링크, 저장소 시작 브랜치, 더 안정적인 채널과 런타임", + changes: [], + features: [ + "Issue에 새 댓글이 오거나 정해 둔 시각에 에이전트를 다시 시작하도록 설정할 수 있습니다.", + "이 재개 규칙은 Issue 사이드바나 오토파일럿에서 관리할 수 있습니다.", + "프로젝트의 저장소 작업을 어느 브랜치나 커밋에서 시작할지 정할 수 있습니다.", + "댓글과 답글의 직접 링크를 복사할 수 있고, 열면 해당 댓글이 강조됩니다.", + "WeCom 답변이 질문한 메시지 안에 돌아옵니다.", + "셀프 호스팅에서 Gitea나 호환 미러로 업데이트를 받을 수 있습니다.", + ], + improvements: [ + "WeCom의 긴 답변이 중간에 사라지지 않고 전부 전달됩니다.", + "페이지가 더 빨리 열리고, 런타임 사용량이 휴대폰 화면에도 들어갑니다.", + ], + fixes: [ + "이름이 같은 도구를 동시에 실행해도 결과가 뒤바뀌지 않습니다.", + "OpenCode 2.x가 실행되고, Oh-My-Pi 사용자 지정 런타임도 제대로 인식·검색됩니다.", + "Telegram 답장이 한 번만 가고, 재시작이나 재시도 뒤에도 중복되지 않습니다.", + "셀프 호스팅의 Telegram과 DingTalk이 설정한 시크릿을 제대로 받습니다.", + "하위 태스크를 취소하면 어느 단계에서 몇 개가 영향을 받았는지 알려 줍니다.", + "댓글 순서가 그대로 유지되고, 화면을 다시 열어도 Issue 링크가 열립니다.", + "로컬 폴더 리소스에 쓸 수 없는 이름 바꾸기가 더 이상 나오지 않습니다.", + "에디터에 붙여 넣은 이미지가 원래 형식을 유지합니다.", + "Inbox의 에이전트 활동 문구가 실제 내용과 맞습니다.", + "Windows 태스크가 추가 단계 없이 결과를 전달합니다.", + ], + }, + { + version: "0.5.0", + date: "2026-09-18", + title: "프랑스어 화면, 더 안정적이고 군더더기 없는 에이전트 실행, Inbox 보관함 전체, 더 오래 유지되는 로그인", + changes: [], + features: [ + "화면 언어로 프랑스어를 고를 수 있고, 웹과 데스크톱 앱 모두 지원합니다.", + "명령줄에서 스킬에 라벨을 달고, 스킬 페이지에서 라벨로 걸러 볼 수 있습니다.", + "Oh-My-Pi 에이전트의 사고 수준을 정할 수 있습니다.", + "명령줄에서 이미 올린 댓글을 고칠 수 있고, 다른 사람이 동시에 한 수정을 덮어쓰지 않습니다.", + "Issue 목록을 속한 프로젝트의 상태로 걸러 볼 수 있습니다.", + "오토파일럿의 일정을 하나씩 고치거나 잠시 멈출 수 있어, 지우고 다시 만들지 않아도 됩니다.", + ], + improvements: [ + "에이전트가 작업을 이어갈 때 Issue와 댓글을 처음부터 다시 읽지 않습니다.", + "계속 사용하면 로그인이 연장되어, 30일마다 강제로 로그아웃되지 않습니다.", + "Inbox 보관함을 가장 오래된 알림까지 넘겨볼 수 있고, 필터와 링크도 전체를 다룹니다.", + "중복된 설명 문구가 사라지고, Chat 목록의 처음 너비가 Inbox와 같아졌습니다.", + ], + fixes: [ + "WeCom에서 잇따라 보내도 메시지가 사라지지 않습니다.", + "에이전트의 WeCom 봇을 바꾸면 이전 봇의 기록이 남지 않습니다.", + "WeCom과 DingTalk 그룹에서 인용한 메시지도 요청과 함께 에이전트에 전달됩니다.", + "DingTalk 답장은 첫 메시지부터 어느 에이전트가 답하는지 알려 줍니다.", + "해지된 연동은 연결이 끊긴 것으로 표시됩니다.", + "Grok, Pi, Copilot, Codex 실행이 조용히 실패하거나 답의 일부를 빠뜨리지 않습니다.", + "Cursor 세션이 연결 시간이 초과된 뒤에도 남아 그대로 이어서 쓸 수 있습니다.", + "오래된 OpenCode가 디스크를 가득 채우지 않습니다.", + "Hermes 태스크가 마무리 단계에서 멈추지 않습니다.", + "데스크톱 앱이 직접 설치한 명령줄 도구를 찾고, CodeBuddy 답장도 온전히 보입니다.", + "Windows에서의 실행이 직접 설정한 도구 경로를 따릅니다.", + "비공개 런타임이 소유자가 맞지 않아 쓰지 못하는 일이 없습니다.", + "태스크의 비용과 사용량이 빠짐없이 기록됩니다.", + "태스크 안의 커밋이 그 태스크의 Git 정보를 사용합니다.", + "다시 연결된 뒤에도 태스크의 최종 결과가 전달됩니다.", + "하위 태스크를 취소하면 상위 단계의 진행이 올바르게 반영됩니다.", + "다른 곳에서 완료된 초대가 대기 상태로 남지 않습니다.", + "멘션 목록이 단어 중간에서도 열리고, 맞는 항목이 없을 때도 조작됩니다.", + "사이드바의 PR 연결과 @all 설명이 오해를 주지 않습니다.", + "Quick Create가 입력한 내용을 그대로 남깁니다.", + ], + }, { version: "0.4.44", date: "2026-09-15", @@ -2979,9 +3096,211 @@ export function createKoDict(allowSignup: boolean): LandingDict { "지금 비슷한 전환점이 다시 오고 있다고 봅니다. 지난 수십 년 동안 소프트웨어 팀은 사실상 단일 스레드로 일해 왔습니다. 엔지니어 한 명이 한 작업을 맡고, 한 번에 하나의 맥락만 다루는 식이었습니다. AI 에이전트는 이 공식을 바꿉니다. Multica는 시분할의 발상을 다시 꺼내 오되, 이번에는 시스템을 함께 쓰는 \"사용자\"가 사람과 자율 에이전트 양쪽을 의미하는 시대에 맞게 다시 풀어냅니다.", "Multica에서 에이전트는 정식 팀원입니다. 사람 동료와 똑같이 태스크를 할당받고, 진행 상황을 보고하고, 막힌 부분을 알리고, 코드를 배포합니다. 담당자 선택, 활동 타임라인, 작업 생명주기, 런타임 인프라는 모두 이 전제를 중심으로 처음부터 설계되었습니다.", "Multics가 그랬듯, 핵심은 multiplexing입니다. 작은 팀이라고 작게 움직일 필요는 없습니다. 올바른 시스템이 있다면 엔지니어 두 명과 에이전트 한 무리가 스무 명짜리 팀처럼 움직일 수 있습니다.", - "Multica는 완전한 오픈소스이며 셀프 호스팅할 수 있습니다. 데이터는 여러분의 인프라 안에 그대로 남습니다. 모든 코드를 들여다보고, API를 확장하고, 원하는 LLM 제공자를 연결하고, 커뮤니티에 기여할 수 있습니다.", + "Multica의 소스 코드는 공개되어 있으며 무료로 셀프 호스팅할 수 있으며, 워크스페이스 데이터는 여러분의 인프라에 저장됩니다. 모든 코드를 들여다보고, API를 확장하고, 원하는 LLM 제공자를 연결하고, 커뮤니티에 기여할 수 있습니다.", ], cta: "GitHub에서 보기", + team: { + title: "Multica를 만드는 사람들", + paragraphs: [ + "Multica는 2021년부터 함께 일해 온 작은 팀이 만들고 있습니다. Multica 이전에는 개발자를 위한 AI 검색 엔진 devv.ai를 만들었습니다. 2025년, 저희가 계속 부딪혀 온 문제, 즉 작은 팀이 AI 에이전트와 함께 실제로 일을 해내는 방법에 집중하기 시작했고, 그 결과가 Multica입니다.", + "소스 코드가 공개되어 있고 셀프 호스팅도 가능하므로, Multica 위에 무언가를 만들기 전에 모든 코드를 직접 확인할 수 있습니다. 셀프 호스팅한 배포는 전부 여러분의 인프라에서 실행됩니다. 상업적 이용 방식은 [라이선스 안내](/licensing) 페이지에서 자세히 설명합니다.", + ], + contacts: [ + { label: "상용 라이선스 및 영업", linkLabel: "영업팀 문의", href: "/contact-sales" }, + { label: "라이선스 안내", linkLabel: "라이선스 FAQ", href: "/licensing" }, + { label: "커뮤니티 및 지원", linkLabel: "Discord", href: discordUrl }, + { label: "소스 코드 및 이슈", linkLabel: "GitHub", href: githubUrl }, + ], + }, + }, + licensing: { + title: "라이선스", + intro: [ + "Multica는 [Multica License](https://github.com/multica-ai/multica/blob/main/LICENSE)로 배포됩니다. Apache License 2.0에 몇 가지 추가 조건을 더한 라이선스입니다. 소스 코드는 공개되어 있으며, 조직 내부에서 Multica를 사용하는 것은 무료입니다. 팀 전체를 위해 셀프 호스팅하는 경우도 마찬가지입니다.", + "가장 중요한 추가 조건은 호스팅 방식의 이용에 관한 것입니다. 조직 외부 사람들에게 Multica를 제공하려면 상용 라이선스가 필요합니다. 이 페이지에서는 자주 받는 질문을 바탕으로 그 기준을 설명합니다. 이해를 돕기 위한 안내일 뿐 법률 자문이 아니며, LICENSE 원문과 다른 부분이 있다면 LICENSE가 우선합니다.", + ], + rule: { + title: "판단 기준", + text: "조직 외부의 누군가가 이 인스턴스를 움직이고 있나요? 즉 태스크를 만들거나, 에이전트와 대화하거나, 작업을 실행시키고 있나요? 그렇다면 웹, Slack, API 등 어떤 경로를 통하든 호스팅 서비스에 해당합니다. 여러분의 팀이 Multica로 만든 결과물만 받아 본다면 내부 사용입니다.", + }, + scenarios: { + title: "자주 묻는 사례", + scenarioColumn: "사례", + licenseColumn: "상용 라이선스", + required: "필요", + notRequired: "불필요", + items: [ + { + scenario: "조직 내부에서 Multica 사용", + example: "셀프 호스팅, 워크스페이스 수와 관계없이.", + required: false, + }, + { + scenario: "고객을 위해 Multica를 구축하고, 고객이 직접 소유하며 내부에서 사용", + example: "도입 지원, 교육, 컨설팅, 커스터마이징 등.", + required: false, + }, + { + scenario: "우리 팀이 Multica로 고객 업무를 하고, 고객은 결과물만 받음", + example: "예: Multica로 콘텐츠 제작을 관리하고 완성본을 납품하는 에이전시.", + required: false, + }, + { + scenario: "에이전트가 고객의 Slack 채널로 리포트나 알림을 일방적으로 보내기만 함", + example: "고객은 메시지를 읽기만 하고 인스턴스와 상호작용하지 않습니다.", + required: false, + }, + { + scenario: "자체 인프라에서 고객을 위해 Multica 인스턴스를 운영·관리", + example: "매니지드 서비스. 요금 부과 여부와 관계없습니다.", + required: true, + }, + { + scenario: "조직 외부 사람이 여러분의 인스턴스에 로그인", + example: "고객, 파트너, 일반 사용자가 자신의 계정을 갖는 경우.", + required: true, + }, + { + scenario: "조직 외부 사람이 다른 경로로 여러분의 인스턴스를 움직임", + example: "Multica를 백엔드로 쓰는 공개 웹사이트, Slack 연동, API 등. 무료로 제공하더라도 마찬가지입니다.", + required: true, + }, + { + scenario: "판매하거나 배포하는 제품에 Multica를 포함", + example: "Multica가 다른 상용 제품의 구성 요소로 제공되는 경우.", + required: true, + }, + ], + }, + sections: [ + { + heading: "기타 조건", + bullets: [ + "브랜딩: 서면으로 브랜딩 면제를 받지 않았다면, Multica 인터페이스에 표시되는 Multica 로고, 제품명, 저작권 및 출처 표시를 제거하거나 변경하지 마세요.", + "출처 표시: Multica 인터페이스 없이 백엔드, 데몬, CLI를 기반으로 제품을 만든다면 저작권 및 NOTICE 정보를 유지하고, 사용자용 문서에 Multica를 기반으로 만들었다는 사실을 [GitHub 저장소](https://github.com/multica-ai/multica) 링크와 함께 밝혀 주세요.", + "포크: 포크의 소스 코드를 공개하는 것 자체는 호스팅 서비스가 아니므로 상용 라이선스가 필요하지 않습니다. 다만 그 포크로 호스팅 서비스를 운영하는 사람은 각자 상용 라이선스를 받아야 합니다.", + "상용 라이선스와 브랜딩 면제는 별개의 허가입니다. 하나를 받았다고 다른 하나가 포함되지는 않습니다.", + ], + }, + { + heading: "상용 라이선스 받기", + paragraphs: [ + "[영업팀 문의](/contact-sales)로 사용 사례를 알려 주시면 영업일 기준 3일 이내에 답변드립니다. 라이선스가 필요한지 잘 모르겠다면 [Discord](" + discordUrl + ")나 같은 양식으로 편하게 물어보세요.", + ], + }, + ], + }, + privacy: { + title: "개인정보 처리방침", + lastUpdated: "최종 업데이트: 2026년 9월 24일", + intro: [ + "이 개인정보 처리방침은 Index Labs (Hong Kong) Limited(이하 “Multica” 또는 “저희”)가 여러분이 multica.ai를 방문하거나, 저희에게 문의하거나, 호스팅 서비스인 Multica Cloud(웹, 데스크톱, 모바일 앱 포함)를 이용할 때 개인정보를 어떻게 수집, 이용, 공유하는지 설명합니다.", + "이 방침은 여러분이 직접 호스팅하는 Multica에는 적용되지 않습니다. 셀프 호스팅 배포의 데이터는 운영자가 관리하며, 어떤 AI 제공자, 연동 서비스, 분석 도구를 쓰는지는 운영자의 설정에 따라 달라집니다. 셀프 호스팅 서버가 저희에게 보내는 것은 하루 한 번의 사용 현황 스냅샷뿐입니다. 여기에는 같은 서버의 스냅샷을 서로 연결하기 위한 무작위 배포 ID, 서버 버전, 워크스페이스·멤버·에이전트·연결된 데몬의 대략적인 수, 그날 시작·완료·실패·취소된 실행 수가 담깁니다. 이름, 이메일 주소, 콘텐츠는 포함되지 않습니다. DO_NOT_TRACK=1로 설정하면 이 스냅샷을 끌 수 있습니다.", + "이 방침은 영어 원문을 기준으로 합니다. 한국어 번역본과 영어 원문의 내용이 다를 경우 영어 원문이 우선합니다.", + ], + sections: [ + { + heading: "수집하는 정보", + bullets: [ + "계정 정보: 이름, 이메일 주소, 프로필 사진. Google로 로그인하면 Google로부터 이름, 이메일 주소, 프로필 사진을 받습니다. 언어, 시간대, 자기소개 같은 프로필 정보와 온보딩 답변(역할, 사용 목적, Multica를 알게 된 경로 등)을 입력할 수도 있습니다.", + "여러분이 만든 콘텐츠: 워크스페이스, 태스크, 댓글, 채팅 메시지, 첨부 파일, 에이전트 지침 등 여러분이나 여러분의 에이전트가 Multica Cloud에 입력하는 모든 것.", + "영업팀 문의: 이름, 업무용 이메일, 회사명과 규모, 국가 또는 지역, 사용 사례, 목표, 연락 수신 설정. 악용을 막기 위해 양식을 제출한 IP 주소와 브라우저 user agent도 기록합니다.", + "결제 정보: 구독 결제는 Stripe가 호스팅하는 페이지에서 Stripe가 처리합니다. 저희는 전체 카드 정보를 받거나 저장하지 않습니다.", + "사용 및 기기 정보: 앱 버전, 운영체제, 클라이언트 유형, 무작위로 생성된 설치 ID, 런타임으로 연결한 각 머신의 이름(기본값은 호스트 이름), 그리고 충돌 및 오류 보고서. 보고서를 보내기 전에 오류 메시지에서 식별 가능한 이메일 주소와 인증 정보를 걸러 내지만, 보고서에는 문제 상황에 관한 다른 정보가 남아 있을 수 있습니다.", + "피드백: 피드백을 보내면 그 내용과 함께 페이지, 앱 버전, 운영체제, 오류 정보를 받습니다.", + ], + }, + { + heading: "정보 이용 목적", + bullets: [ + "Multica Cloud의 제공, 운영, 보호(로그인, 워크스페이스 동기화, 알림 및 초대 발송 등).", + "영업팀 문의와 지원 요청에 대한 응답.", + "로그인 코드, 워크스페이스 초대 등 서비스 메시지 발송. 제품 소식이나 마케팅 정보는 동의한 경우에만 보내며, 언제든지 수신을 거부할 수 있습니다.", + "Multica 이용 현황 파악, 버그 수정, 제품 개선.", + "악용 방지와 법적 의무 이행.", + ], + }, + { + heading: "처리의 법적 근거", + paragraphs: [ + "처리의 법적 근거를 밝혀야 하는 지역에서는 다음을 근거로 개인정보를 처리합니다. Multica Cloud를 제공하기 위한 여러분과의 계약 이행, Multica의 보안 유지·지원·개선 및 문의 응대에 관한 저희의 정당한 이익, 마케팅 정보 수신에 대한 여러분의 동의, 그리고 법적 의무의 준수입니다.", + ], + }, + { + heading: "AI 기능", + paragraphs: [ + "코딩 에이전트는 여러분이 설정한 코딩 도구와 계정으로, 여러분의 머신이나 연결한 런타임에서 실행됩니다. 에이전트가 로컬에서 실행된다고 해서 모델 추론까지 로컬에서 이뤄지는 것은 아닙니다. 이런 도구는 프롬프트, 코드, 파일, 도구 실행 결과를 각자의 모델 제공자에게 보내며, 그 처리는 여러분이 쓰는 도구와 계정의 약관을 따릅니다. Multica는 에이전트의 작업을 조율합니다.", + "채팅 제목 생성이나 후속 작업 제안 같은 Multica Cloud의 일부 기능은 결과를 만들기 위해 첫 채팅 메시지나 최근 몇 개의 메시지를 저희가 선택한 외부 대규모 언어 모델 제공자에게 보냅니다. Multica는 여러분의 콘텐츠를 AI 모델 학습에 사용하지 않습니다.", + ], + }, + { + heading: "쿠키와 분석", + paragraphs: [ + "로그인 상태 유지, 사이트 간 요청 위조 방지, 업로드한 파일 접근에 필요한 쿠키를 사용합니다. 또한 어떤 캠페인이나 웹사이트를 통해 방문했는지를 최대 30일 동안 기억하는 쿠키와, 언어 및 마지막으로 연 워크스페이스를 기억하는 쿠키도 사용합니다.", + "제품 이용 현황 파악과 충돌 보고서 수집에는 PostHog를 사용합니다. 로그인한 상태에서는 보고서를 계정과 연결하기 위해 계정의 이름과 이메일이 PostHog로 전송됩니다. 광고용 쿠키는 사용하지 않으며, 여러분의 개인정보를 판매하지 않습니다.", + ], + }, + { + heading: "정보 공유 대상", + paragraphs: [ + "워크스페이스에 입력한 정보는 워크스페이스 권한에 따라 다른 멤버와 관리자, 그리고 이들이 허용한 에이전트와 연동 서비스가 볼 수 있습니다. 워크스페이스가 조직에 속해 있다면 그 조직이 콘텐츠를 관리하며, 관련 요청도 조직이 처리할 수 있습니다.", + "법령이 요구하는 경우 정보를 공개하며, Multica가 합병, 인수, 자산 매각의 대상이 되는 경우 정보가 인수자나 승계자에게 이전될 수 있습니다.", + "그 밖에는 Multica 운영을 돕는 서비스 제공자, 그리고 여러분이 연결하기로 선택한 연동 서비스와만 개인정보를 공유합니다.", + ], + bullets: [ + "Amazon Web Services: 호스팅, 파일 저장, 콘텐츠 전송", + "Vercel: 웹사이트 및 웹 앱 호스팅", + "Stripe: 결제 및 청구", + "Resend: 로그인 및 초대 이메일", + "PostHog: 제품 분석 및 충돌 보고서", + "Google: Google 로그인을 선택한 경우", + "대규모 언어 모델 제공자: 위에서 설명한 AI 기능", + "여러분이 연결한 연동 서비스(Slack, Lark, DingTalk, WeCom, Telegram, GitHub, GitLab, Composio로 연결한 앱 등): 여러분이 이를 통해 주고받는 데이터이며, 각 서비스의 약관도 적용됩니다", + ], + }, + { + heading: "정보 저장 위치", + paragraphs: [ + "Multica Cloud는 Amazon Web Services와 Vercel에서 호스팅됩니다. 저희와 서비스 제공자는 미국 및 기타 국가나 지역에서 여러분의 정보를 처리할 수 있습니다. 어디에서 처리되든 이 방침에 따라 정보를 보호합니다.", + ], + }, + { + heading: "보관 기간", + paragraphs: [ + "계정 정보와 워크스페이스 콘텐츠는 계정이나 워크스페이스가 존재하는 동안 보관합니다. 워크스페이스 소유자가 워크스페이스를 삭제하면 태스크, 댓글 등 그 안의 콘텐츠는 Multica Cloud에서 제거됩니다. 다만 복구용 백업에는 삭제 후 일정 기간 사본이 남아 있을 수 있습니다. 삭제된 워크스페이스에 업로드된 파일을 파일 저장소에서 지우고 싶다면 [support@multica.ai](mailto:support@multica.ai)로 이메일을 보내 주세요. 결제 기록은 회계 및 세무 규정이 요구하는 기간 동안 보관하고, 제품 분석 데이터, 충돌 보고서, 영업팀 문의, 피드백은 지원과 제품 개선에 필요한 기간 동안 보관합니다. 문의와 피드백은 요청하시면 삭제합니다.", + ], + }, + { + heading: "여러분의 선택과 권리", + paragraphs: [ + "거주 지역의 법령에 따라 여러분은 개인정보의 열람, 정정, 삭제, 내보내기를 요청하고, 특정 처리에 반대하거나 이를 제한하고, 이미 한 동의(예: 마케팅 정보 수신 동의)를 철회하고, 해당 지역의 개인정보 보호 감독기관에 불만을 제기할 권리가 있을 수 있습니다. 프로필은 Multica에서 언제든지 수정할 수 있고, 소유한 워크스페이스는 설정에서 삭제할 수 있습니다. 계정 삭제 등 그 밖의 요청은 [support@multica.ai](mailto:support@multica.ai)로 이메일을 보내 주세요. 30일 이내에 답변드립니다.", + ], + }, + { + heading: "보안", + paragraphs: [ + "전송 구간 암호화, 접근 제어, 연동 서비스 인증 정보의 암호화 저장 등으로 여러분의 정보를 보호합니다. 완벽하게 안전한 시스템은 없으므로, 계정이 도용되었다고 생각되면 바로 연락해 주세요.", + ], + }, + { + heading: "아동", + paragraphs: [ + "Multica는 16세 미만 아동을 대상으로 하지 않으며, 아동의 개인정보를 알면서 수집하지 않습니다.", + ], + }, + { + heading: "방침 변경", + paragraphs: [ + "이 방침은 수시로 업데이트될 수 있습니다. 새 버전은 이 페이지에 게시하고 상단의 날짜를 업데이트합니다. 중요한 변경이 있으면 시행 전에 알려 드립니다.", + ], + }, + { + heading: "문의하기", + paragraphs: [ + "Multica는 Index Labs (Hong Kong) Limited가 운영하며, 여러분의 개인정보에 대한 책임을 집니다. 개인정보 관련 질문이나 요청은 [support@multica.ai](mailto:support@multica.ai)로 이메일을 보내 주세요.", + ], + }, + ], }, download: { hero: { @@ -3040,6 +3359,9 @@ export function createKoDict(allowSignup: boolean): LandingDict { title: "CLI가 더 편하신가요?", sub: "서버, 원격 개발 환경, headless 환경에 적합합니다. 데스크톱과 동일한 데몬을 터미널에서 바로 설치할 수 있습니다.", installLabel: "설치", + platformGroup: "플랫폼 선택", + platformMacosLinux: "macOS / Linux", + platformWindows: "Windows", startLabel: "데몬 시작", sshNote: "이미 서버에 접속해 있나요? 같은 명령을 SSH에서도 그대로 사용할 수 있습니다.", copyLabel: "복사", @@ -3137,17 +3459,17 @@ export function createKoDict(allowSignup: boolean): LandingDict { ], consent: { intro: - "Multica, Inc.는 여러분의 개인정보를 소중히 다룹니다. 제공해 주신 개인정보는 계정 관리와 요청하신 제품·서비스 제공에만 사용합니다. 가끔씩 제품 업데이트, 활용 팁, 도움이 될 만한 인사이트도 함께 전해 드리고 싶습니다. 소식을 받고 싶으시다면 아래에서 선택해 주세요.", + "Multica는 여러분의 개인정보를 소중히 다룹니다. 제공해 주신 개인정보는 계정 관리와 요청하신 제품·서비스 제공에만 사용합니다. 가끔씩 제품 업데이트, 활용 팁, 도움이 될 만한 인사이트도 함께 전해 드리고 싶습니다. 소식을 받고 싶으시다면 아래에서 선택해 주세요.", outreach: - "서비스 업데이트, 지원 문의, 비즈니스 관련 후속 연락 등 Multica, Inc.로부터 개별 연락을 받겠습니다.", + "서비스 업데이트, 지원 문의, 비즈니스 관련 후속 연락 등 Multica로부터 개별 연락을 받겠습니다.", updates: "Multica의 제품 업데이트, 인사이트, 이벤트 초대 소식을 받겠습니다.", unsubscribe: "언제든 수신을 거부할 수 있습니다. 개인정보와 데이터 권리를 어떻게 다루는지는 다음 문서에서 자세히 확인하실 수 있습니다:", submitConsent: - "\"제출\"을 클릭하시면 요청하신 콘텐츠를 보내 드리기 위해 Multica, Inc.가 정보를 저장하고 처리하는 것에 동의하게 됩니다.", + "\"제출\"을 클릭하시면 요청하신 콘텐츠를 보내 드리기 위해 Multica가 정보를 저장하고 처리하는 것에 동의하게 됩니다.", privacyLinkLabel: "개인정보 처리방침.", - privacyLinkHref: "/about", + privacyLinkHref: "/privacy", }, success: { title: "감사합니다. 요청을 잘 받았습니다.", diff --git a/apps/web/features/landing/i18n/types.ts b/apps/web/features/landing/i18n/types.ts index ba28e4edac0..da5ab3daf1f 100644 --- a/apps/web/features/landing/i18n/types.ts +++ b/apps/web/features/landing/i18n/types.ts @@ -35,6 +35,14 @@ type FeatureSection = { cards: { title: string; description: string }[]; }; +// Long-form page copy. Paragraphs and bullets may embed `[label](href)` +// links, rendered by InlineLinks. +export type DocumentSection = { + heading: string; + paragraphs?: string[]; + bullets?: string[]; +}; + type FooterGroup = { label: string; links: { label: string; href: string }[]; @@ -85,6 +93,7 @@ export type LandingDict = { headlineLine2: string; description: string; cta: string; + licensingCta: string; highlights: { title: string; description: string }[]; }; faq: { @@ -117,6 +126,31 @@ export type LandingDict = { }; paragraphs: string[]; cta: string; + team: { + title: string; + paragraphs: string[]; + contacts: { label: string; linkLabel: string; href: string }[]; + }; + }; + licensing: { + title: string; + intro: string[]; + rule: { title: string; text: string }; + scenarios: { + title: string; + scenarioColumn: string; + licenseColumn: string; + required: string; + notRequired: string; + items: { scenario: string; example?: string; required: boolean }[]; + }; + sections: DocumentSection[]; + }; + privacy: { + title: string; + lastUpdated: string; + intro: string[]; + sections: DocumentSection[]; }; changelog: { title: string; @@ -183,6 +217,9 @@ export type LandingDict = { title: string; sub: string; installLabel: string; + platformGroup: string; + platformMacosLinux: string; + platformWindows: string; startLabel: string; sshNote: string; copyLabel: string; diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 47123632dbd..6a07b5f11af 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -1,7 +1,10 @@ import { githubUrl, discordUrl } from "../components/shared"; import type { LandingDict } from "./types"; -export function createZhDict(allowSignup: boolean): LandingDict { +export function createZhDict( + allowSignup: boolean, + docsHref: string, +): LandingDict { return { header: { github: "GitHub", @@ -19,7 +22,7 @@ export function createZhDict(allowSignup: boolean): LandingDict { headlineLine1: "\u4f60\u7684\u4e0b\u4e00\u6279\u5458\u5de5", headlineLine2: "\u4e0d\u662f\u4eba\u7c7b\u3002", subheading: - "Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 智能体 \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + 智能体 \u56e2\u961f\u3002", + "Multica 是一个源码公开的平台,\u5c06\u7f16\u7801 智能体 \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + 智能体 \u56e2\u961f\u3002", cta: "免费开始", downloadDesktop: "下载桌面端", talkToSales: "联系商务", @@ -155,17 +158,18 @@ export function createZhDict(allowSignup: boolean): LandingDict { }, openSource: { - label: "\u5f00\u6e90", - headlineLine1: "\u5f00\u6e90", - headlineLine2: "\u4e3a\u6240\u6709\u4eba\u3002", + label: "源码公开", + headlineLine1: "每一行代码,", + headlineLine2: "都由你掌控。", description: - "Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + 智能体 \u534f\u4f5c\u7684\u672a\u6765\u3002", - cta: "\u5728 GitHub \u4e0a Star", + "Multica 的源代码完全公开。审查每一行代码,免费自托管,塑造人类 + 智能体 协作的未来。把 Multica 作为托管服务提供给他人,需要商业授权。", + cta: "在 GitHub 上 Star", + licensingCta: "了解授权方式 →", highlights: [ { title: "\u968f\u5904\u81ea\u6258\u7ba1", description: - "\u5728\u4f60\u81ea\u5df1\u7684\u57fa\u7840\u8bbe\u65bd\u4e0a\u8fd0\u884c Multica\u3002Docker Compose\u3001\u5355\u4e2a\u4e8c\u8fdb\u5236\u6216 Kubernetes\u2014\u2014\u4f60\u7684\u6570\u636e\u6c38\u8fdc\u4e0d\u4f1a\u79bb\u5f00\u4f60\u7684\u7f51\u7edc\u3002", + "\u5728\u4f60\u81ea\u5df1\u7684\u57fa\u7840\u8bbe\u65bd\u4e0a\u8fd0\u884c Multica\u3002Docker Compose\u3001\u5355\u4e2a\u4e8c\u8fdb\u5236\u6216 Kubernetes——工作区数据始终保存在你自己掌控的服务器上。", }, { title: "\u65e0\u4f9b\u5e94\u5546\u9501\u5b9a", @@ -192,12 +196,17 @@ export function createZhDict(allowSignup: boolean): LandingDict { { question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 智能体\uff1f", answer: - "Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 26 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aAntigravity\u3001Claude Code\u3001CodeBuddy\u3001CodeArts\u3001Codex\u3001Copilot\u3001Cursor\u3001DeepSeek Harness\u3001DevEco Code\u3001Dim\u3001Grok\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001MiniMax Code\u3001Oh-My-Pi\u3001OpenClaw\u3001OpenCode\u3001Pi\u3001Qoder\u3001Qoder CN\u3001Qwen Code\u3001QwenPaw\u3001Reasonix\u3001Trae CLI\u3001ZeroClaw\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002", + "Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 26 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aAntigravity\u3001Claude Code\u3001CodeBuddy\u3001CodeArts\u3001Codex\u3001Copilot\u3001Cursor\u3001DeepSeek Harness\u3001DevEco Code\u3001Dim\u3001Grok\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001MiniMax Code\u3001Oh-My-Pi\u3001OpenClaw\u3001OpenCode\u3001Pi\u3001Qoder\u3001Qoder CN\u3001Qwen Code\u3001QwenPaw\u3001Reasonix\u3001Trae CLI\u3001ZeroClaw\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002因为源码公开,你也可以自己添加后端。", }, { question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f", answer: - "\u4e24\u8005\u90fd\u6709\u3002\u4f60\u53ef\u4ee5\u7528 Docker Compose \u6216 Kubernetes \u5728\u81ea\u5df1\u7684\u57fa\u7840\u8bbe\u65bd\u4e0a\u81ea\u6258\u7ba1 Multica\uff0c\u4e5f\u53ef\u4ee5\u4f7f\u7528\u6211\u4eec\u7684\u6258\u7ba1\u4e91\u7248\u672c\u3002\u4f60\u7684\u6570\u636e\uff0c\u4f60\u9009\u62e9\u3002", + "\u4e24\u8005\u90fd\u6709\u3002\u4f60\u53ef\u4ee5\u7528 Docker Compose \u6216 Kubernetes \u5728\u81ea\u5df1\u7684\u57fa\u7840\u8bbe\u65bd\u4e0a\u81ea\u6258\u7ba1 Multica\uff0c\u4e5f\u53ef\u4ee5\u4f7f\u7528\u6211\u4eec\u7684\u6258\u7ba1\u4e91\u7248\u672c\u3002你的数据,你选择。", + }, + { + question: "可以商用吗?", + answer: + "可以。在你自己的组织内部使用 Multica 是免费的,包括为整个团队自托管。只有两种情况需要商业授权:把 Multica 提供给组织外部的人使用(比如作为托管服务或代运维服务),或者把它嵌入你销售或分发的产品中。常见场景见[授权说明](/licensing)。", }, { question: @@ -213,19 +222,19 @@ export function createZhDict(allowSignup: boolean): LandingDict { { question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1f智能体 \u5728\u54ea\u91cc\u6267\u884c\uff1f", answer: - "智能体 \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002", + "智能体在你的机器(通过本地守护进程)或你连接的运行时上运行,直接在你的代码仓库里工作。工作区里的内容——任务、评论、聊天消息、附件,以及智能体上报的进度——由 Multica 存储;智能体所用的编码工具会把提示词和代码发送给你配置的模型服务商。想让工作区数据留在自己的服务器上,可以自托管 Multica。详见[隐私政策](/privacy)。", }, { question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a 智能体\uff1f", answer: - "\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a 智能体 \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002", + "\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a 智能体 \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002自托管时没有任何人为限制。", }, ], }, footer: { tagline: - "\u4eba\u7c7b + 智能体 \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002", + "\u4eba\u7c7b + 智能体 团队的项目管理。源码公开、可自托管、\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002", cta: "\u5f00\u59cb\u4f7f\u7528", groups: { product: { @@ -241,7 +250,7 @@ export function createZhDict(allowSignup: boolean): LandingDict { resources: { label: "\u8d44\u6e90", links: [ - { label: "\u6587\u6863", href: "/docs/zh" }, + { label: "\u6587\u6863", href: docsHref }, { label: "API", href: githubUrl }, { label: "X (Twitter)", href: "https://x.com/MulticaAI" }, { label: "Discord", href: discordUrl }, @@ -250,8 +259,9 @@ export function createZhDict(allowSignup: boolean): LandingDict { company: { label: "\u5173\u4e8e", links: [ - { label: "\u5173\u4e8e\u6211\u4eec", href: "/about" }, - { label: "\u5f00\u6e90", href: "#open-source" }, + { label: "关于我们", href: "/about" }, + { label: "授权说明", href: "/licensing" }, + { label: "隐私政策", href: "/privacy" }, { label: "\u8054\u7cfb\u5546\u52a1", href: "/contact-sales" }, { label: "GitHub", href: githubUrl }, ], @@ -278,9 +288,213 @@ export function createZhDict(allowSignup: boolean): LandingDict { "\u6211\u4eec\u8ba4\u4e3a\uff0c\u7c7b\u4f3c\u7684\u8f6c\u6298\u70b9\u6b63\u5728\u518d\u6b21\u51fa\u73b0\u3002\u51e0\u5341\u5e74\u6765\uff0c\u8f6f\u4ef6\u56e2\u961f\u4e00\u76f4\u5904\u4e8e\u4e00\u79cd\u5355\u7ebf\u7a0b\u7684\u5de5\u4f5c\u6a21\u5f0f\uff0c\u4e00\u4e2a\u5de5\u7a0b\u5e08\u5904\u7406\u4e00\u4e2a\u4efb\u52a1\uff0c\u4e00\u6b21\u53ea\u4e13\u6ce8\u4e8e\u4e00\u4e2a\u4e0a\u4e0b\u6587\u3002AI agents \u6539\u53d8\u4e86\u8fd9\u4e2a\u7b49\u5f0f\u3002Multica \u5c06\u201c\u5206\u65f6\u201d\u91cd\u65b0\u5e26\u56de\u8fd9\u4e2a\u65f6\u4ee3\uff0c\u53ea\u4e0d\u8fc7\u4eca\u5929\u5728\u7cfb\u7edf\u4e2d\u8fdb\u884c\u591a\u8def\u590d\u7528\u7684\u201c\u7528\u6237\u201d\uff0c\u65e2\u5305\u62ec\u4eba\u7c7b\uff0c\u4e5f\u5305\u62ec\u81ea\u4e3b\u4ee3\u7406\u3002", "在 Multica 中,agents 是一级团队成员。它们会被分配任务,汇报进展,提出阻塞,并交付代码,就像人类同事一样。任务分配、活动时间线、task 生命周期,以及运行时基础设施,Multica 从第一天起就是围绕这一理念构建的。", "\u548c\u5f53\u5e74\u7684 Multics \u4e00\u6837\uff0c\u8fd9\u4e00\u5224\u65ad\u5efa\u7acb\u5728\u201c\u591a\u8def\u590d\u7528\u201d\u4e4b\u4e0a\u3002\u4e00\u4e2a\u5c0f\u56e2\u961f\u4e0d\u8be5\u56e0\u4e3a\u4eba\u6570\u5c11\u5c31\u663e\u5f97\u80fd\u529b\u6709\u9650\u3002\u6709\u4e86\u5408\u9002\u7684\u7cfb\u7edf\uff0c\u4e24\u540d\u5de5\u7a0b\u5e08\u52a0\u4e0a\u4e00\u7ec4 agents\uff0c\u5c31\u80fd\u53d1\u6325\u51fa\u4e8c\u5341\u4eba\u56e2\u961f\u7684\u63a8\u8fdb\u901f\u5ea6\u3002", - "\u8fd9\u4e2a\u5e73\u53f0\u662f\u5b8c\u5168\u5f00\u6e90\u5e76\u652f\u6301\u81ea\u6258\u7ba1\u7684\u3002\u4f60\u7684\u6570\u636e\u59cb\u7ec8\u4fdd\u7559\u5728\u81ea\u5df1\u7684\u57fa\u7840\u8bbe\u65bd\u4e2d\u3002\u4f60\u53ef\u4ee5\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6269\u5c55 API\uff0c\u63a5\u5165\u81ea\u5df1\u7684 LLM providers\uff0c\u4e5f\u53ef\u4ee5\u5411\u793e\u533a\u8d21\u732e\u4ee3\u7801\u3002", + "Multica 的源代码公开,并且可以免费自托管,工作区数据始终保存在你自己的基础设施中。\u4f60\u53ef\u4ee5\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6269\u5c55 API\uff0c\u63a5\u5165\u81ea\u5df1\u7684 LLM providers\uff0c\u4e5f\u53ef\u4ee5\u5411\u793e\u533a\u8d21\u732e\u4ee3\u7801\u3002", + ], + cta: "在 GitHub 上查看", + team: { + title: "Multica 背后的团队", + paragraphs: [ + "Multica 由一支从 2021 年起就一起工作的小团队打造。在 Multica 之前,我们做过面向开发者的 AI 搜索引擎 devv.ai。2025 年,我们开始解决自己反复遇到的问题:一个小团队到底该怎样和 AI 智能体一起把事情做完。这就是 Multica。", + "Multica 的源代码公开,也可以自托管:在基于它构建之前,你可以读完每一行代码;自托管的部署完全运行在你自己的基础设施上。商业使用的规则,我们在[授权说明](/licensing)里写清楚了。", + ], + contacts: [ + { label: "商业授权与合作", linkLabel: "联系商务", href: "/contact-sales" }, + { label: "授权规则", linkLabel: "授权说明", href: "/licensing" }, + { label: "社区与支持", linkLabel: "Discord", href: discordUrl }, + { label: "源代码与问题反馈", linkLabel: "GitHub", href: githubUrl }, + ], + }, + }, + + licensing: { + title: "授权说明", + intro: [ + "Multica 采用 [Multica License](https://github.com/multica-ai/multica/blob/main/LICENSE) 发布:在 Apache License 2.0 的基础上附加了几项条件。源代码公开,在你自己的组织内部使用 Multica 是免费的,包括为整个团队自托管。", + "最主要的附加条件针对托管使用:把 Multica 提供给组织外部的人使用,需要商业授权。这一页用大家最常问的问题,说明这条线划在哪里。这是一份通俗说明,不构成法律意见;如与 LICENSE 原文不一致,以 LICENSE 为准。", + ], + rule: { + title: "一条判断标准", + text: "看组织外部的人有没有在驱动这个实例——创建任务、和智能体对话、触发工作。只要有,不管通过什么界面(Web、Slack 还是 API),都算托管服务;如果他们只是收到你的团队用 Multica 做出来的成果,就属于内部使用。", + }, + scenarios: { + title: "常见场景", + scenarioColumn: "场景", + licenseColumn: "商业授权", + required: "需要", + notRequired: "不需要", + items: [ + { + scenario: "你的组织内部使用 Multica", + example: "自托管,不限工作区数量。", + required: false, + }, + { + scenario: "你帮客户部署 Multica,由客户自己拥有、在其组织内部使用", + example: "实施、培训、咨询或定制开发。", + required: false, + }, + { + scenario: "你的团队用 Multica 为客户干活,客户只收到交付物", + example: "例如 agency 在 Multica 里管理内容生产,向客户交付成品。", + required: false, + }, + { + scenario: "智能体只向客户的 Slack 频道单向推送报告或通知", + example: "客户只看消息,不与实例做任何交互。", + required: false, + }, + { + scenario: "你在自己的基础设施上替客户运行和管理 Multica 实例", + example: "即代运维服务(managed service),无论是否收费。", + required: true, + }, + { + scenario: "组织外部的人登录你的实例", + example: "客户、合作伙伴或公众拥有自己的账号。", + required: true, + }, + { + scenario: "组织外部的人通过其他入口驱动你的实例", + example: "例如接入 Multica 后端的公开网站、Slack 集成或 API,免费提供也一样。", + required: true, + }, + { + scenario: "你把 Multica 嵌入到你销售或分发的产品中", + example: "Multica 作为另一个商业产品的组件一起交付。", + required: true, + }, + ], + }, + sections: [ + { + heading: "其他条件", + bullets: [ + "品牌:除非获得我们的书面品牌豁免,请保留 Multica 界面中显示的 Multica Logo、产品名称以及版权和署名信息。", + "署名:如果你只基于 Multica 的后端、守护进程或 CLI 构建产品、不使用 Multica 界面,需要保留版权和 NOTICE 信息,并在面向用户的文档中注明产品基于 Multica 构建,附上 [GitHub 仓库](https://github.com/multica-ai/multica)链接。", + "Fork:公开发布 fork 的源代码不算托管服务,不需要商业授权。但任何用这个 fork 运营托管服务的人,都需要各自获得商业授权。", + "商业授权和品牌豁免是两项独立的授权,获得其中一项不代表获得另一项。", + ], + }, + { + heading: "获取商业授权", + paragraphs: [ + "通过[联系商务](/contact-sales)告诉我们你的使用场景,我们会在三个工作日内回复。不确定自己的情况是否需要授权?可以在 [Discord](" + discordUrl + ") 上问我们,也可以通过同一个表单咨询。", + ], + }, + ], + }, + + privacy: { + title: "隐私政策", + lastUpdated: "最后更新:2026 年 9 月 24 日", + intro: [ + "本隐私政策说明 Index Labs (Hong Kong) Limited(下称「Multica」或「我们」)在你访问 multica.ai、联系我们或使用我们的托管服务 Multica Cloud(包括网页端、桌面端和移动端)时,如何收集、使用和共享个人信息。", + "本政策不适用于你自行部署的 Multica。自托管部署的数据由部署方控制,它使用哪些 AI 服务商、集成或分析工具,取决于部署方的配置。自托管服务器唯一会发送给我们的是每天一次的使用快照,内容包括:一个随机生成的部署 ID(用于关联同一台服务器的历次快照)、服务器版本、工作区、成员、智能体和已连接守护进程的大致数量,以及当天开始、完成、失败和取消的运行次数。快照不含任何姓名、邮箱或内容。设置 DO_NOT_TRACK=1 即可关闭这份快照。", + "本政策以英文版本为准。如中文版本与英文版本不一致,以英文版本为准。", + ], + sections: [ + { + heading: "我们收集的信息", + bullets: [ + "账户信息:你的姓名、邮箱地址和头像。如果你使用 Google 登录,我们会从 Google 获得你的姓名、邮箱地址和头像。你也可以填写语言、时区、个人简介等资料,以及回答上手引导中的问题,例如你的角色、使用场景、从哪里了解到 Multica。", + "你创建的内容:工作区、任务、评论、聊天消息、附件、智能体指令,以及你或你的智能体放进 Multica Cloud 的其他内容。", + "联系商务表单:你的姓名、工作邮箱、公司名称和规模、国家或地区、使用场景、目标,以及你的沟通偏好。为了防止滥用,我们还会记录提交表单时的 IP 地址和浏览器 user agent。", + "账单信息:订阅付款由 Stripe 在其托管的页面上处理,我们不会接收或存储你的完整银行卡信息。", + "使用和设备信息:应用版本、操作系统、客户端类型和一个随机生成的安装 ID;你连接为运行时的每台机器的名称(默认是其主机名);以及崩溃和错误报告。报告发送前,我们会从错误信息中过滤掉能识别出的邮箱地址和凭据,但报告仍可能包含与出错情况相关的其他细节。", + "反馈:你提交反馈时,我们会收到反馈内容,以及所在页面、应用版本、操作系统和相关的错误信息。", + ], + }, + { + heading: "我们如何使用信息", + bullets: [ + "提供、运营和保护 Multica Cloud,包括登录、同步工作区、发送通知和邀请。", + "回复联系商务表单和支持请求。", + "发送服务消息,例如登录验证码和工作区邀请。只有在你主动同意后,我们才会发送产品动态或营销信息,你可以随时退订。", + "了解 Multica 的使用情况、修复问题并改进产品。", + "防止滥用,并履行法律义务。", + ], + }, + { + heading: "法律依据", + paragraphs: [ + "在法律要求说明处理依据的地区,我们依据以下几点处理个人信息:履行与你之间的合同,以提供 Multica Cloud;我们在保障安全、提供支持、改进 Multica 和回复咨询方面的正当利益;你对接收营销信息的同意;以及履行我们的法律义务。", + ], + }, + { + heading: "AI 功能", + paragraphs: [ + "你的编码智能体运行在你自己的机器或你连接的运行时上,使用的是你配置的编码工具和账户。智能体在本机运行,不代表模型也在本机推理:这些工具会把提示词、代码、文件和工具调用结果发送给各自的模型服务商,并受你所用工具和账户的条款约束。Multica 负责协调智能体的工作。", + "Multica Cloud 的部分功能(如生成聊天标题和推荐后续操作)会把你的第一条聊天消息或最近几条消息,发送给我们选用的第三方大语言模型服务商来生成结果。Multica 不会用你的内容训练 AI 模型。", + ], + }, + { + heading: "Cookie 与分析", + paragraphs: [ + "我们使用必要的 Cookie 来保持你的登录状态、防范跨站请求伪造,以及让你访问自己上传的文件。我们还会用 Cookie 记住你是通过哪个推广活动或网站来到这里的(最长 30 天),以及你选择的语言和最近打开的工作区。", + "我们使用 PostHog 了解产品使用情况并收集崩溃报告。你登录后,PostHog 会收到你账户的姓名和邮箱,以便我们把报告和你的账户对应起来。我们不使用广告 Cookie,也不出售你的个人信息。", + ], + }, + { + heading: "我们与谁共享信息", + paragraphs: [ + "你放进工作区的信息,会按工作区的权限设置,被其他成员和管理员,以及他们授权的智能体和集成看到。如果你的工作区属于某个组织,其中的内容由该组织管理,相关请求也可能由该组织处理。", + "在法律要求时,我们会披露相关信息;如果 Multica 发生合并、收购或资产出售,相关信息也可能转移给买方或继任方。", + "除此之外,我们只会与帮助我们运营 Multica 的服务商,以及你选择连接的集成共享个人信息:", + ], + bullets: [ + "Amazon Web Services:托管、文件存储和内容分发", + "Vercel:网站和网页应用托管", + "Stripe:付款和账单", + "Resend:登录和邀请邮件", + "PostHog:产品分析和崩溃报告", + "Google:当你选择使用 Google 登录时", + "大语言模型服务商:上文所述的 AI 功能", + "你连接的集成,如 Slack、飞书、钉钉、企业微信、Telegram、GitHub、GitLab、通过 Composio 连接的应用:你选择通过它们收发的数据,同时受这些服务商自身条款的约束", + ], + }, + { + heading: "信息的存储位置", + paragraphs: [ + "Multica Cloud 托管在 Amazon Web Services 和 Vercel 上。我们和我们的服务商可能会在美国及其他国家或地区处理你的信息。无论在哪里处理,我们都会按照本政策保护这些信息。", + ], + }, + { + heading: "信息的保留期限", + paragraphs: [ + "账户信息和工作区内容会在你的账户或工作区存在期间一直保留。工作区所有者删除工作区后,其中的任务、评论等内容会从 Multica Cloud 中移除,但用于恢复的备份在之后一段时间内仍可能包含副本。如需从我们的文件存储中清除已删除工作区里上传的文件,请发邮件至 [support@multica.ai](mailto:support@multica.ai)。账单记录按会计和税务规定要求的期限保留;产品分析数据、崩溃报告、联系商务表单和反馈,会在为你提供支持和改进 Multica 所需的期间内保留。你可以要求我们删除联系商务表单和反馈。", + ], + }, + { + heading: "你的选择和权利", + paragraphs: [ + "根据你所在地的法律,你可能有权访问、更正、删除或导出你的个人信息,反对或限制某些处理,撤回你已给出的同意(例如接收营销信息的同意),以及向当地的数据保护机构投诉。你可以随时在 Multica 中更新个人资料,也可以在设置中删除你拥有的工作区。其他请求(包括删除账户),请发邮件至 [support@multica.ai](mailto:support@multica.ai),我们会在 30 天内回复。", + ], + }, + { + heading: "安全", + paragraphs: [ + "我们通过传输加密、访问控制以及对集成凭据的加密存储来保护你的信息。没有任何系统是绝对安全的;如果你认为自己的账户已被盗用,请尽快联系我们。", + ], + }, + { + heading: "儿童", + paragraphs: [ + "Multica 并非面向 16 岁以下的儿童,我们也不会在知情的情况下收集他们的个人信息。", + ], + }, + { + heading: "政策变更", + paragraphs: [ + "我们可能会不时更新本政策。新版本会发布在本页面,并更新页首的日期。如有重大变更,我们会在生效前通知你。", + ], + }, + { + heading: "联系我们", + paragraphs: [ + "Multica 由 Index Labs (Hong Kong) Limited 运营,并由其负责你的个人信息。如有隐私相关的问题或请求,请发邮件至 [support@multica.ai](mailto:support@multica.ai)。", + ], + }, ], - cta: "\u5728 GitHub \u4e0a\u67e5\u770b", }, changelog: { @@ -293,6 +507,113 @@ export function createZhDict(allowSignup: boolean): LandingDict { fixes: "问题修复", }, entries: [ + { + version: "0.5.2", + date: "2026-09-23", + title: "运行中任务追加指令、Issue 重复标记、任务运行更可靠", + changes: [], + features: [ + "Claude Code、Codex 的任务在运行过程中也能补充新的指导。", + "可以在状态选择器里把 Issue 标记为重复,一键跳回原 Issue,列表里也看得到。", + "命令行创建 Issue 时可以同时写好自定义属性。", + "在 Telegram 群里 @ 智能体,它已经知道近期的对话。", + "下载页可以直接获取 Windows 上的命令行安装方式。", + ], + improvements: [ + "OpenClaw 的每个智能体都在你为它配置的目录里工作。", + "创建 Issue 时上传的附件会出现在描述里。", + "Lark 机器人不回话时,能看到投递卡在哪里。", + "Issue 的定时唤醒按你自己的时区显示。", + "进入任务对应的 GitHub PR 更快了。", + "运行状态的动效更流畅,也更省资源。", + ], + fixes: [ + "Codex 的新模型一发布就出现在选择器里。", + "命令行登录连不上服务器时会明确告知,不再一直等。", + "受邀成员在限制注册的自托管环境里也能完成注册。", + "启动没有确认的任务会被重新拉起,不会卡住。", + "取消任务立刻响应,线程里的回复也会送到对应的智能体。", + "移动端断线后会自己重新连上。", + "桌面端工具栏的间距恢复正常。", + "Windows 安装脚本在 PowerShell 5.1 上也能运行。", + "法语界面的确认框不再出现横向滚动。", + "Autopilot 创建的 Issue 会记录在活动里。", + "访客身份的小队负责人会被正常唤醒并接手工作。", + "企业微信的回复没送回来时,能查出是哪里丢的。", + ], + }, + { + version: "0.5.1", + date: "2026-09-21", + title: "Issue 唤醒规则、评论直链、项目仓库起始分支、渠道与运行时更稳", + changes: [], + features: [ + "Issue 可以设置成有新评论时或按定时规则再次唤醒智能体。", + "唤醒规则可以在 Issue 侧栏或 Autopilot 里管理。", + "项目的仓库工作可以指定从哪个分支或提交开始。", + "评论和回复都能复制直链,打开后会定位并高亮它。", + "企业微信的回答会回在你提问的那条消息里。", + "自托管可以改用 Gitea 或其兼容镜像获取更新。", + ], + improvements: [ + "企业微信的超长回答会完整送达,不再整条丢失。", + "页面打开更快,运行时用量在手机上也排得下。", + ], + fixes: [ + "同时运行的同名工具不再把结果弄混。", + "OpenCode 2.x 可以运行,Oh-My-Pi 的自定义运行时也能正常识别和发现。", + "Telegram 每条消息只回一次,重启或重试后也不会重复。", + "自托管的 Telegram 和钉钉能正确读到你填的密钥。", + "取消子任务时会说明影响了哪个阶段、影响了多少个。", + "评论的顺序保持稳定,重新打开页面后 Issue 链接也依然可用。", + "本地目录资源不再出现无法使用的重命名入口。", + "编辑器里粘贴的图片会保留原本的格式。", + "Inbox 里关于智能体活动的文案与实际一致了。", + "Windows 上的任务不用额外操作就能交付结果。", + ], + }, + { + version: "0.5.0", + date: "2026-09-18", + title: "法语界面、智能体运行更稳更省、Inbox 完整归档、登录状态更持久", + changes: [], + features: [ + "界面语言可以选法语,网页端和桌面端都支持。", + "命令行可以给技能加标签,技能页面也能按标签筛选。", + "Oh-My-Pi 的智能体可以设置思考级别。", + "命令行可以修改已经发出的评论,且不会覆盖别人同时的修改。", + "Issue 列表可以按所属项目的状态筛选。", + "Autopilot 的每条日程都能单独编辑或暂停,不用删掉重建。", + ], + improvements: [ + "智能体接着上一轮继续时,不用再把整个 Issue 和评论重读一遍。", + "一直在使用时登录状态会自动延长,不再每 30 天被强制退出。", + "Inbox 的归档可以一直往回翻到最早的通知,筛选和链接也覆盖全部。", + "各处重复的说明文字精简了,Chat 列表的初始宽度与 Inbox 一致。", + ], + fixes: [ + "企业微信连续发多条消息时不再丢消息。", + "给智能体换企业微信机器人后,旧机器人不会再留下记录。", + "企业微信和钉钉群里引用的那条消息会一并交给智能体。", + "钉钉的回复从第一条起就标明是哪个智能体在回答。", + "已被撤销的群聊连接会显示为已断开。", + "Grok、Pi、Copilot、Codex 的运行不再静默出错或漏掉部分回复。", + "Cursor 的会话在连接超时后仍然保留,可以接着用。", + "过旧的 OpenCode 不会再把磁盘写满。", + "Hermes 的任务不再卡在收尾阶段。", + "桌面端能找到你自己装的命令行工具,CodeBuddy 的回复也完整显示。", + "Windows 上的运行会按你设置的路径找工具。", + "私有运行时不再因为归属对不上而无法使用。", + "任务的费用和用量不再漏记。", + "任务里的提交会用这个任务自己的 Git 身份。", + "断线重连后,任务的最终结果依然会送达。", + "取消子任务后,父任务的阶段进度会正确推进。", + "在别处完成的邀请不会再留在待处理里。", + "提及选择器在词中间也能打开,没有匹配时也能正常操作。", + "侧栏里关于 PR 关联和 @all 的说明不再有误导。", + "Quick Create 会保留你原本输入的内容。", + ], + }, { version: "0.4.44", date: "2026-09-15", @@ -3513,6 +3834,9 @@ export function createZhDict(allowSignup: boolean): LandingDict { title: "想用 CLI?", sub: "适合服务器、远程开发机、无图形界面环境。底层 daemon 与 Desktop 相同,通过终端安装。", installLabel: "安装", + platformGroup: "选择你的系统", + platformMacosLinux: "macOS / Linux", + platformWindows: "Windows", startLabel: "启动 daemon", sshNote: "已经在服务器上?通过 SSH 执行同样的命令即可。", copyLabel: "复制", @@ -3611,17 +3935,17 @@ export function createZhDict(allowSignup: boolean): LandingDict { ], consent: { intro: - "Multica, Inc. 尊重你的隐私。我们仅会将你的个人信息用于管理账户,以及提供你所请求的产品或服务。我们偶尔也希望与你分享产品更新、最佳实践或行业洞察,如果你愿意接收,请在下方勾选。", + "Multica 尊重你的隐私。我们仅会将你的个人信息用于管理账户,以及提供你所请求的产品或服务。我们偶尔也希望与你分享产品更新、最佳实践或行业洞察,如果你愿意接收,请在下方勾选。", outreach: - "我希望接收来自 Multica, Inc. 的一对一沟通,包括服务更新、支持咨询以及业务相关的跟进。", + "我希望接收来自 Multica 的一对一沟通,包括服务更新、支持咨询以及业务相关的跟进。", updates: "我希望接收 Multica 的产品更新、洞察以及活动邀请。", unsubscribe: "你可以随时取消订阅我们的邮件。关于我们如何处理你的数据以及隐私权利,请参阅", submitConsent: - "点击「提交」即表示你同意 Multica, Inc. 存储并处理你提交的信息,以便交付你请求的内容。", + "点击「提交」即表示你同意 Multica 存储并处理你提交的信息,以便交付你请求的内容。", privacyLinkLabel: "隐私政策。", - privacyLinkHref: "/about", + privacyLinkHref: "/privacy", }, success: { title: "已收到,谢谢!", diff --git a/apps/web/lib/docs-href.test.ts b/apps/web/lib/docs-href.test.ts index 7eacacf4db2..b2e4f89b0b5 100644 --- a/apps/web/lib/docs-href.test.ts +++ b/apps/web/lib/docs-href.test.ts @@ -8,5 +8,6 @@ describe("docsHrefForLocale", () => { expect(docsHrefForLocale("zh-Hans")).toBe("/docs/zh"); expect(docsHrefForLocale("ko")).toBe("/docs/ko"); expect(docsHrefForLocale("ja")).toBe("/docs/ja"); + expect(docsHrefForLocale("fr")).toBe("/docs/fr"); }); }); diff --git a/apps/web/lib/docs-href.ts b/apps/web/lib/docs-href.ts index 812f764f8f1..dadc284a3ef 100644 --- a/apps/web/lib/docs-href.ts +++ b/apps/web/lib/docs-href.ts @@ -4,5 +4,6 @@ export function docsHrefForLocale(locale: SupportedLocale): string { if (locale === "zh-Hans") return "/docs/zh"; if (locale === "ko") return "/docs/ko"; if (locale === "ja") return "/docs/ja"; + if (locale === "fr") return "/docs/fr"; return "/docs"; } diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818fbb2..a419cbe4e3a 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 99750cfad04..06764554206 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@multica/web", - "version": "0.4.44", + "version": "0.5.2", "private": true, "type": "module", "scripts": { diff --git a/deploy/coderpush/images.yml b/deploy/coderpush/images.yml new file mode 100644 index 00000000000..8843f609d2e --- /dev/null +++ b/deploy/coderpush/images.yml @@ -0,0 +1,7 @@ +# Layer over /opt/multica/compose.yml; preserve its ports, env and volumes. +# Supply digest references after both images from one main commit are verified. +services: + backend: + image: ${CODERPUSH_BACKEND_IMAGE:?Set the verified backend image digest} + frontend: + image: ${CODERPUSH_WEB_IMAGE:?Set the verified frontend image digest} diff --git a/deploy/helm/multica/templates/backend.yaml b/deploy/helm/multica/templates/backend.yaml index 21e678f7989..bf34da6977d 100644 --- a/deploy/helm/multica/templates/backend.yaml +++ b/deploy/helm/multica/templates/backend.yaml @@ -1,4 +1,6 @@ {{- $backendImageTag := default .Chart.AppVersion .Values.images.backend.tag -}} +{{- /* `helm upgrade --reuse-values` from a chart without extraCACerts carries no such key. */ -}} +{{- $extraCACerts := (default dict .Values.backend.extraCACerts).configMap -}} {{- if .Values.backend.uploads.persistence.enabled -}} apiVersion: v1 kind: PersistentVolumeClaim @@ -52,6 +54,10 @@ spec: # rendered value there is a placeholder; on a real install/upgrade it # reflects the actual Secret and rolls pods on rotation. checksum/secret: {{ (lookup "v1" "Secret" .Release.Namespace .Values.existingSecret).data | toYaml | sha256sum }} + {{- if $extraCACerts }} + # Same lookup pattern: the backend reads extra CAs only at startup. + checksum/extra-ca-certs: {{ (lookup "v1" "ConfigMap" .Release.Namespace $extraCACerts).data | toYaml | sha256sum }} + {{- end }} labels: {{- include "multica.labels" . | nindent 8 }} app.kubernetes.io/component: backend @@ -68,15 +74,31 @@ spec: name: {{ .Release.Name }}-config - secretRef: name: {{ .Values.existingSecret }} - {{- if not .Values.postgres.external.enabled }} + {{- if or (not .Values.postgres.external.enabled) $extraCACerts }} env: + {{- if not .Values.postgres.external.enabled }} - name: DATABASE_URL value: {{ include "multica.databaseUrl" . | quote }} + {{- end }} + {{- if $extraCACerts }} + # Go loads the system bundle file plus every file in these + # directories. SSL_CERT_DIR replaces the default directory list, + # so the system directory stays in it alongside the extra CAs. + - name: SSL_CERT_DIR + value: /etc/ssl/certs:/etc/multica/ca-certs + {{- end }} {{- end }} - {{- if .Values.backend.uploads.persistence.enabled }} + {{- if or .Values.backend.uploads.persistence.enabled $extraCACerts }} volumeMounts: + {{- if .Values.backend.uploads.persistence.enabled }} - name: uploads mountPath: /app/data/uploads + {{- end }} + {{- if $extraCACerts }} + - name: extra-ca-certs + mountPath: /etc/multica/ca-certs + readOnly: true + {{- end }} {{- end }} # The entrypoint runs `./migrate up` before serving traffic. On a # cold cluster (Postgres still coming up) this can take minutes. The @@ -114,11 +136,18 @@ spec: tolerations: {{- toYaml . | nindent 8 }} {{- end }} - {{- if .Values.backend.uploads.persistence.enabled }} + {{- if or .Values.backend.uploads.persistence.enabled $extraCACerts }} volumes: + {{- if .Values.backend.uploads.persistence.enabled }} - name: uploads persistentVolumeClaim: claimName: {{ include "multica.backend.fullname" . }}-uploads + {{- end }} + {{- if $extraCACerts }} + - name: extra-ca-certs + configMap: + name: {{ $extraCACerts }} + {{- end }} {{- end }} --- apiVersion: v1 diff --git a/deploy/helm/multica/values.yaml b/deploy/helm/multica/values.yaml index a44fc560fc2..1ef312a8144 100644 --- a/deploy/helm/multica/values.yaml +++ b/deploy/helm/multica/values.yaml @@ -108,6 +108,16 @@ backend: # backend replicas without S3. accessModes: - ReadWriteOnce + # Extra CA certificates for the backend's outbound HTTPS — e.g. a self-hosted + # Gitea / Forgejo / GitLab whose certificate comes from an internal CA. Set + # configMap to an existing ConfigMap in the release namespace whose keys are + # PEM certificate files. The chart mounts it read-only and adds it to the + # system trust store via SSL_CERT_DIR: public CAs stay trusted and TLS + # verification stays on. The backend reads the certificates at startup; + # `helm upgrade` rolls it when the ConfigMap changed, otherwise run + # `kubectl rollout restart` after updating it. + extraCACerts: + configMap: "" # All non-secret backend env. Secret values come from `existingSecret`. config: # Optional container-loopback maintenance API; empty disables it. Never expose via Service/Ingress. diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index e4684630dce..c0b3db2880e 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -156,10 +156,13 @@ services: # and callers fall back silently rather than failing. MULTICA_LLM_MAX_RETRIES # is the retry budget: unset = 2, 0 = disabled, 1-5 = an exact ceiling. # Anything else fails the boot instead of being silently corrected. + # MULTICA_LLM_DISABLE_THINKING asks gateways that honor the field to + # turn model reasoning off; see .env.example for the contract. MULTICA_LLM_API_KEY: ${MULTICA_LLM_API_KEY:-} MULTICA_LLM_BASE_URL: ${MULTICA_LLM_BASE_URL:-} MULTICA_LLM_DEFAULT_MODEL: ${MULTICA_LLM_DEFAULT_MODEL:-} MULTICA_LLM_MAX_RETRIES: ${MULTICA_LLM_MAX_RETRIES:-} + MULTICA_LLM_DISABLE_THINKING: ${MULTICA_LLM_DISABLE_THINKING:-} # Lark / Feishu bot integration. MULTICA_LARK_SECRET_KEY is the # opt-in: unset = integration disabled. Mainland 飞书 and international # Lark are auto-detected per installation and served side by side, so @@ -178,6 +181,11 @@ services: # in the database, so this single deployment-wide key is all the operator # needs to set here. MULTICA_SLACK_SECRET_KEY: ${MULTICA_SLACK_SECRET_KEY:-} + # DingTalk bot integration. MULTICA_DINGTALK_SECRET_KEY is the opt-in: + # unset = integration disabled. It encrypts each installation's AppSecret + # at rest, so this single deployment-wide key is all the operator needs + # to set here. + MULTICA_DINGTALK_SECRET_KEY: ${MULTICA_DINGTALK_SECRET_KEY:-} # Self-hosted Git provider integration (Forgejo / Gitea / GitLab). This # is a self-host-only feature, so the compose file turns it on by default; # the managed cloud leaves it unset (off). It still needs a valid @@ -202,6 +210,12 @@ services: # for a debugging session and unset it when that session ends, because # what it records is user message content. MULTICA_WECOM_TRACE: ${MULTICA_WECOM_TRACE:-} + # Telegram bot integration. MULTICA_TELEGRAM_SECRET_KEY is the + # opt-in: unset = integration disabled. It decrypts the + # per-installation bot token stored in channel_installation.config + # (bot_token_encrypted), so this single deployment-wide key is all + # the operator needs to set here. + MULTICA_TELEGRAM_SECRET_KEY: ${MULTICA_TELEGRAM_SECRET_KEY:-} restart: unless-stopped frontend: diff --git a/docs/engineering/issue-wakeups.md b/docs/engineering/issue-wakeups.md new file mode 100644 index 00000000000..c9fac1517c6 --- /dev/null +++ b/docs/engineering/issue-wakeups.md @@ -0,0 +1,398 @@ +# Issue wakeups + +An agent can save an event subscription or a timer on an issue, finish its run, +and receive another ordinary run when the input arrives. Business completion is +still decided by the agent after reading current state. There is no sleeping +process, business-condition evaluator, or second run lifecycle. + +## Product contract + +The shared web/Desktop issue sidebar lists event and time wakeups together, +as compact trigger/target summaries with a separate execution status when available. +Instructions, full filters, errors and +latest-run transcript are in the details popover. A directly visible toggle +controls enabled configurations and restores manually disabled subscriptions or +recurring schedules. Consumed one-shots offer Cancel this execution while unclaimed, +then Enable again (events) or Set a new time (time). +Wakeup history is collapsed by default. A consumed one-shot remains +in the current group while its run is queued, deferred, dispatched or running. +Running or already +claimed tasks use the normal Stop controls. Terminal lifecycle categories +(`done` and `closed`, including custom statuses) disable all configurations; +reopening does not reactivate them. + +Board and list activity cues prioritize current runs, mark wakeup-origin runs, +and show enabled wakeup rules as a separate count, not a forecast of executions. +Without an active run they show the +next scheduled time (including a date when needed) or waiting for an event. +A shared workspace summary request contains exact enabled counts and at most +three previews per issue, never prompts or history. Access follows shared issue visibility and workspace membership; private +source-agent and source-run filters are redacted consistently. The issue surface polls once every ten +seconds; cards select their own rows from that shared cache. + +Restoring an interval schedules from now; cron uses its next future occurrence, +without replaying missed times. An unconsumed future one-shot can be toggled back +on; expired or consumed time wakeups require choosing a new future time. One-shot +rearming is refused while a previous run is active. Closed issues cannot enable +wakeups; reopening an issue still requires manual enable. + +The additive `POST /api/issues/{id}/wakeups/{wakeupID}/enable` accepts the observed +`revision`, plus optional `rearm` and future `at`. It loads stored configuration +under the existing issue/configuration locks and reuses Save's validation, +authorization, receipt cleanup and revision fencing. A stale revision returns +409, and a repeated already-enabled request with the current revision is a no-op. +Clients do not resend instructions, filters or thread references. The new UI +requires this endpoint for restore; deploy the server first. Old clients and the +existing full-config CLI update continue to work without a migration. + +Agents manage configurations with `multica issue wakeup`: + +```sh +multica issue wakeup events +multica issue wakeup create ISSUE --kind at --after 10m --instruction-file ./instruction.md +multica issue wakeup create ISSUE --kind every --every 1h --instruction-file ./instruction.md +multica issue wakeup create ISSUE --kind cron --cron '0 * * * *' --timezone Asia/Shanghai --instruction-file ./instruction.md +multica issue wakeup create ISSUE --kind event --event task.completed,task.failed,task.cancelled --task-id RUN --instruction-file ./instruction.md +multica issue wakeup list ISSUE +multica issue wakeup get ISSUE WAKEUP +multica issue wakeup disable ISSUE WAKEUP +``` + +Specify `--agent-id` for human callers; authenticated agents default to themselves. +Event subscriptions default to `once`; `--mode continuous` keeps listening. +A concrete source run must belong to this issue. Registration checks its current +terminal state under a lock, so a run that just finished is not missed. A busy +source run returns 409 and asks the caller to retry; registration never waits +on a source lock while holding the issue lock. The server returns the stable +`wakeup_source_busy` code for this rolled-back conflict; CLI create retries it +once after 250ms. Other conflicts and ambiguous network failures are not retried. +Broad +agent filters observe future facts, without replaying history or following retry +chains. `--parent COMMENT` preserves the original result-delivery thread. + +`update ISSUE WAKEUP` accepts the complete create configuration, replaces it, +increments its revision, withdraws old unclaimed inputs, and explicitly enables +it. The creator or a workspace owner/admin may update or disable; the replacing +caller must themselves be allowed to invoke the selected agent and becomes the +new configuration's recorded human principal. + +The scheduler checks approximately every 30 seconds. One-shot timers remain +queued while their runtime is offline. Repeating timers coalesce missed periods +into one pending check and continue from the next future time; they do not replay +every historical tick. All runs retain normal comment delivery, including checks +that find no change. CI can be polled by the agent; CI push events are not claimed +as supported by this version. + +## Event catalog + +`multica issue wakeup events` lists the 25 supported issue-scoped subscriptions: + +| Area | Events | +| --- | --- | +| Run | `task.queued`, `task.dispatched`, `task.started`, `task.deferred`, `task.waiting_local_directory`, `task.completed`, `task.failed`, `task.cancelled` | +| Issue | `issue.updated`, `issue.status_changed`, `issue.assignee_changed`, `issue.parent_changed`, `issue.project_changed`, `issue.labels_changed`, `issue.properties_changed`, `issue.metadata_changed` | +| Comment | `comment.created`, `comment.updated`, `comment.deleted`, `comment.resolved`, `comment.unresolved` | +| Reaction | `reaction.added`, `reaction.removed` (issue or comment) | +| Attachment | `attachment.attached`, `attachment.detached` (issue or comment) | + +`task.started` means the persisted run entered `running`. Retries emit a new +`task.queued` with `retry_of_task_id`; manual reruns carry `rerun_of_task_id`. +Queue/defer transitions may happen repeatedly. Unchanged writes, bookkeeping +revisions, duplicate reactions, and pruning an already-deleted comment do not +produce another fact. Attachment events describe binding to an issue/comment, +not uploading an unbound file; moving a file produces detach and attach facts. + +`issue.updated` includes `changed_fields` for meaningful issue fields, excluding +position, revision and timestamps. Specialized issue events can accompany it; +subscribing to both produces two inputs that the normal dispatcher coalesces. +Metadata/properties events include changed keys, never their values. Comment +events contain comment/thread/parent references, not bodies. Attachment events +contain references, not private URLs or filenames. Agents pull current state to +decide what to do. There is no business-condition evaluator. + +Each newly captured fact includes `event_id`, `event_type`, `version`, +`occurred_at`, workspace/issue IDs, actor identity and optional source run/agent. +The already-terminal registration snapshot additionally has `observed_at` and +`registration_snapshot`; its `occurred_at` can be null for historical runs with +no completion timestamp. Older queued payloads remain readable. + +`--filter-agent-id` selects the run's agent for task events. New mutation-only +requests using this legacy flag normalize to `filter_actor_type=agent` and +`filter_actor_id`; use actor flags for comment/issue/reaction/attachment changes. +Existing stored rules and legacy mixed task/mutation requests remain supported. Editing another agent's comment does not make that +agent the editor; payloads distinguish actor from author. `--task-id` accepts +only run events. Filters never expand the subscription beyond its current issue. + +Use `--filter-actor-type member --filter-actor-id USER_ID` to wait for a +specific workspace member, or type `agent` for a source agent. For example: + +```sh +multica issue wakeup create ISSUE --kind event --event comment.created \ + --filter-actor-type member --filter-actor-id USER_ID \ + --instruction-file ./instruction.md +``` + +These optional paired filters apply to issue, comment, reaction and attachment +changes. They match the actor who performed the change; for a new comment this +is its author, but for an edit it is the editor, not the original author. Unknown +system attribution does not match. Task subscriptions keep their existing +agent/run filters. Actor filters cannot be combined with those source filters. +The ID is the user's UUID, not the membership record's UUID; it must belong to +the current workspace. Other people's events neither enqueue nor consume a +one-shot rule. Broad subscriptions without actor filters behave as before. +Enable/rearm retains the stored actor filter; an explicit full update can replace +or clear it. The sidebar, board summary and inventory show the actor's name; +private agent references are redacted, retaining a generic selected-actor label. + +Migrations 531–532 add nullable actor columns and update transactional capture. +Apply migrations, then deploy the API/CLI before creating actor-filtered rules; +update web/Desktop to display the actor restriction. Old clients can read the +additive response, but cannot show the new restriction. On application rollback, +keep these migrations: the database still enforces stored filters even for older +consumers. Schema rollback deliberately refuses while any actor-filtered rule +exists, including disabled rules, so a later re-enable cannot silently broaden +it. Disable/drain and remove those configurations before rolling 532/531 back. + +The platform lifecycle names `issue.created` and `issue.deleted` are listed +separately and rejected for self-wakeups: subscription requires an existing +issue, and deleting it withdraws its work. Workspace/cross-issue subscriptions, +external CI push events, and expanding the plugin subscription contract are +outside this version. Terminal issue transitions disable wakeups rather than +starting a final run on the closed issue. + +## Implementation + +- `pkg/eventcontract` owns stable business-event names independently of plugins. + Plugin constants alias the existing names, preserving their payload contract. +- `issue_wakeup` stores configuration and `issue_wakeup_receipt` stores matching + inputs. Run transitions and the collaboration changes in the catalog above + capture matching receipts in the source transaction. SQL capture hooks cover + service, scheduler and HTTP writers without a best-effort in-memory hop. They + do not build a general event archive or evaluate business predicates. +- Registration is prospective once committed; agents should subscribe before + querying current state. The explicit run filter also checks current state + during registration. There is no global event order or historical replay API. +- The existing scheduler leases `issue_wakeup_dispatch`. The adapter locks one + issue/configuration, rechecks scope and the creator's current invoke rights, + and consumes receipts together with ordinary task enqueue. Failure leaves the + receipt available for retry. Claim rechecks permissions after an offline wait. +- Event notifications merge by rule, revision and event type, keeping the first + occurrence time, count and latest source reference. Agents read current state + and comment history when intermediate references have been condensed. + Time inputs replace the pending time note with the newest signal. A claimed + prompt is immutable; later input becomes at most one subsequent queued task. +- Mutations and run events from the same wakeup's run are ignored. HTTP mutation + transactions stamp server-resolved actor and source task identity in local + PostgreSQL settings; those settings do not survive connection reuse. A client + cannot choose source identity through a request body or an untrusted task header. +- `context.wakeup_id` and `context.wakeup_revision` identify the new trigger. + Bounded structured facts live in additive `context.wakeup_evidence` (version 1). + Its instruction/facts travel in the ordinary per-turn handoff note. Daemon + wakeup prompts preserve this instruction even when a delivery thread exists. + Automatic retries inherit both context and note. +- The existing pending-task index is retained. For wakeup tasks only, its derived + `comment_thread_id` scheduling scope is the configuration ID; the real delivery + thread stays in `trigger_comment_id`. This preserves old retry SQL during a + rolling server upgrade. Comment/assign coalescing excludes wakeup inputs, and + the existing issue/agent execution fence still serializes actual runs. +- Issue/workspace deletion explicitly removes configurations and receipts in + the application deletion graph. No foreign keys or cascading relationships + are added. + +## Deployment and verification + +Apply additive migrations before starting the new server. Existing pending-task +indexes are not rebuilt or dropped, and historical queue rows are not rewritten. +Deploy the updated CLI and daemon with the server to recognize the wakeup command +and per-turn prompt. Before rollback, disable/drain wakeups; do not remove their +configuration tables while tasks still reference them. + +Migration 520 adds capture hooks without indexes, table rewrites, or foreign +keys. Deploy it before admitting subscriptions to the expanded catalog. Older +servers still dispatch the added receipts and older sidebars fall back to raw +event names; only updated servers accept create/update with new event types. +During a rolling upgrade, mutation attribution from older HTTP servers is best +effort (some older write paths provide no source identity). Keep new event +subscriptions disabled until all mutation-serving instances are upgraded, so +their own writes cannot feed back without attribution. Before rolling application +code back, disable subscriptions using the expanded catalog; before rolling the +capture migration back, drain their inputs as well. The down migration restores +the original five-event capture behavior and retains configuration/receipt data. + +Migration 521 adds a concurrent partial index for enabled workspace summaries. +It can be rolled back independently of configuration data. Deploy the server +before the UI: an older server lacks the summary endpoint, so cards cannot +show future wakeups until it is upgraded. Existing task activity still works. +New detail fields are optional for rolling compatibility. + +Migration 523 excludes the registering run's own events, in addition to events +from runs produced by the same rule. Human/external events with no source run +still match. Apply this migration before enabling broad agent-created subscriptions. +Rolling it back restores the previous capture function without rewriting data; +disable affected subscriptions first to avoid registration feedback. + +This unmerged branch's wakeup migrations use prefixes 509–532, following +main's migrations through 508. The previously used 500–523 wakeup names were +renumbered by +9 without changing their SQL or relative execution order. + +Before updating a local database that has already applied the old branch, +stop its API and daemon and rename only the exact wakeup +`schema_migrations.version` entries from the old filenames to the new filenames +(+9). Do not rename main's similarly numbered migrations or rerun the base +wakeup table creation. Databases still on the older 495–508 wakeup names first +need the previous +5 rename to the 500–513 names, then this +9 rename. +Keep this ledger change transactional; leave all wakeup rules, receipts and +queued tasks intact. Fresh databases use the normal migration runner. + + +Dispatch keeps the instruction and recent evidence within a 40,000-byte prompt +budget. Large or older details are explicitly condensed; original receipts remain +linked to the task and are consumed atomically with its queue update. A claimed +prompt is immutable, so further inputs remain pending until it starts or recovers. +After the normal 90-second claim recovery window with an expired/absent prepare +lease, `last_error` exposes that wait. Existing runtime claim recovery owns retries; +wakeups do not add another execution timeout. Timer progress advances while waiting. + +Integration coverage includes transaction rollback, already-terminal registration, +source scope, one-shot deduplication, independent comment/assign input, merging, +self-loop suppression, custom terminal statuses, reopen behavior, offline timers, +configuration replacement, revoked permission at enqueue/claim, and retry prompt +inheritance. UI tests cover disabling and consumed one-shot state; API response +schemas reject malformed wakeup state rather than presenting an empty list. +Expanded-event integration tests execute writes for every advertised event and +cover attachment rebinding, repeated queue transitions, tombstone cleanup, +meaningful-change suppression, rollback, actor/author distinction, forged source +headers, metadata redaction and self-loop suppression on HTTP mutations. + +## Workspace management + +Web and Desktop expose **Issue wakeups** inside the Autopilot page (`?tab=wakeups`). +The tab remains available without any autopilots. `GET /api/issue-wakeups` returns +an access-filtered inventory with counts, agent filter choices, and offset +pagination (50 by default, maximum 100). Scope, trigger kind, target agent, and +literal case-insensitive search run on the server; page rows and counts share +one database snapshot. Prompts are omitted from this collection response. + +Active means an enabled rule on an open issue **or** an unfinished run, including +consumed one-shot rules and manually disabled rules with running work. The query +looks up runs by their wakeup context, so a retry remains visible even if the +rule's last-task pointer refers to an older attempt. Configuration state and run +state are separate columns. Rules on terminal issues appear under Ended once +all their runs finish. Counts include every rule on shared issues, including rules targeting private +agents. Reading a rule does not grant permission to invoke or configure its +agent. Private source-agent and source-run references are redacted in inventory, +sidebar and board summary responses alike. Instructions remain shared issue +content and are omitted from inventory and summary responses. + +Trigger labels describe conditions (for example, "When Emacs's run succeeds"), +not an outcome that has already happened. The monitored agent and specific run +are distinct from the agent to wake. Multiple event types mean any of those events. +Rule labels distinguish waiting, scheduled, turned off, triggered, expired, and +stopped because the issue ended. A consumed one-shot is Triggered even when its +execution is still queued or has failed. A manually disabled rule remains Turned +off even if its previous execution succeeded. Execution results have their own +labels and transcript entrypoint. Familiar schedules use natural language, while +details retain the original cron and timezone. Disabled one-time rules retain +their original scheduled time. + +The sidebar and inventory share enable, resubscribe, reschedule, and withdrawal +controls. Batch disable is limited to explicitly selected enabled rules on the +current page. It confirms that already-started runs continue, sends bounded +sequential calls to the existing authorized disable endpoint, and retains only +failed selections for retry. The inventory polls every ten seconds; mutations +invalidate inventory, sidebar, board summaries, and task caches. No scheduler or +Autopilot execution semantics change. + + +## Database load and notification retention + +The scheduler discovers candidates from due timers and the partial pending-receipt +index, instead of scanning disabled rule history. Each dispatch has a two-second +budget and a 50 ms PostgreSQL lock timeout; diagnostic/fairness writes have their +own 100 ms budgets. Contention leaves inputs pending for the next tick and does +not spend the full batch deadline on one issue. The global inventory counts only +unfinished runs before pagination; indexed latest-run lookups happen for the +selected page, rather than sorting the entire completed-run history. + +At most 32 rules per issue and 1,000 per workspace can be enabled. These are +activation limits: disabled history does not count, existing rules are not +silently removed, and editing an already-enabled rule does not consume another +slot. A database trigger serializes activation within a workspace, including +concurrent creation on different issues. Capacity errors return HTTP 400 with +`wakeup_capacity_exceeded`. These initial limits bound synchronous source-event +fanout and can be revisited with measured workload data. + +New event captures retain at most one pending notification per rule revision +and event type (25 currently supported types). Repeated inputs retain a count, +first occurrence time and the latest source reference, with an explicit prompt +instruction to read source state. This is a wakeup notification, not an immutable +event archive or a promise to execute once per source event. Legacy pending +receipts remain readable and drain in batches of 100. Processed receipts become +eligible for deletion after seven days, with up to 1,000 deleted per scheduler +tick; backlog can extend that retention. Pending inputs are never age-expired. +Run history and source comments are unaffected. Receipt keys suppress retained +first/latest fact duplicates; they are not a permanent deduplication ledger for +all intermediate coalesced facts. + +Migrations 524–527 add concurrent lookup/expiry indexes, 528 adds a nullable +coalescing key, 529 adds its partial unique index, and 530 enables bounded capture +and activation limits. Apply in that order before the new server. No existing +receipt rewrite is needed. New consumers lock receipt rows before constructing +queue evidence. Each merge also rotates its receipt ID: an old consumer can only +mark the version it read as processed, so a concurrent replacement remains +pending during rolling upgrades (a redundant check is possible, lost new input +is avoided). Existing event/task keys and API fields remain compatible. + +Rolling application code back leaves the database limits and coalescing active; +old consumers can still drain the notifications. To roll the schema back, reverse +530 before dropping 529/528 and the additive indexes. This restores the prior +capture function and removes limits without deleting rules or pending inputs. +The base wakeup tables must still be retained while any wakeup runs reference them. + +## Summary compatibility and capture locks + +Pending-run evidence is merged as structured facts and rendered once, without +parsing a previous prompt's headings or omission messages. The total rendered +note still fits 40KB. Older tasks, or notes rewritten by an older dispatcher, +are carried as one opaque bounded legacy item; overflowing history is marked +as condensed and the latest input remains readable. Existing readers and retries +continue using `handoff_note`; no migration or new execution lifecycle is needed. + +Dispatch takes the pending-task lock before locking event receipts, so waiting +for an existing task does not hold up capture on those receipt rows. Receipt +consumption and task enqueue/update still commit in one transaction. Source +writes can still wait during that short critical section; this is not a promise +of nonblocking comment writes. Splitting enqueue from consumption would require +another delivery/recovery protocol and is intentionally avoided. + +Direct self-trigger protection is scoped to one rule and trusted source-run +identity. Cross-rule cycles remain possible; avoid mutually triggering continuous +comment subscriptions. Prefer a member actor filter for waiting on a human. +Target agent names follow shared issue visibility, while source references and +invocation/management authority keep their existing checks. + + +### Editing wakeup instructions + +The issue sidebar details and Autopilot wakeup list share a prompt editor. The +workspace list loads the issue's prompts only when the editor opens. Saving uses +`PATCH /api/issues/:id/wakeups/:wakeupID/instruction` with `instruction`, +`expected_instruction`, and `revision`; success returns 204. The existing full +replacement PUT still has its original reconfiguration/rearm semantics. + +Prompt-only edits preserve schedule, enabled/consumed state, creator, provenance, +subscription revision, pending receipts and existing runs. Later dispatch uses the +new instructions (including when merging new evidence into a still-pending run); +saving itself does not rewrite queued or running tasks. Structured evidence keeps +the instruction used for its last rendering, so editing does not turn structured +facts into a nested old prompt. Pre-snapshot notes remain bounded historical +context, explicitly subordinate to the current instruction. Closed/disabled rules may +be edited without resuming them. The caller must be the creator or a workspace +admin/owner and must currently be allowed to invoke the target agent. + +Under the issue/config locks, both the prior prompt and subscription revision +must match; otherwise saving returns 409 and the UI retains the draft. Keeping +the subscription revision avoids invalidating receipts and queued work on a text +edit. This additive endpoint needs no migration and does not change existing +clients. Deploy the API before using the editor; an older API rejects the new +endpoint and the editor keeps the unsaved text. diff --git a/docs/operations/coderpush-production.md b/docs/operations/coderpush-production.md new file mode 100644 index 00000000000..59682206f7d --- /dev/null +++ b/docs/operations/coderpush-production.md @@ -0,0 +1,504 @@ +# CoderPush Multica production + +This fork, `CoderPush/multica`, is the working repository for Multica enhancements +and production operations. Open enhancement PRs against this fork's `main`, not +upstream. Production environment changes are performed on the host; record their +non-secret intent and verification here. Never commit credentials or database dumps. + +For the new team workspace, squad, runtime setup and remaining onboarding steps, +see [CoderFactory team setup](coderfactory.md). + +## Verified inventory — 18 September 2026 + +Production is **AWS Lightsail Singapore**, not Hetzner. Hetzner was evaluated +before the Lightsail deployment on 15 September. DNS and SSH verified the current +host; the instance size and snapshot schedule below come from the deployment +handover and have not been rechecked in AWS this session. + +| Item | Value | +| --- | --- | +| Application | https://multica.coderbase.dev | +| Fork | https://github.com/CoderPush/multica, default branch `main` | +| Upstream | https://github.com/multica-ai/multica | +| Host | `52.76.32.3`; SSH user `ubuntu` | +| Recorded instance | `multica-coderpush`, ap-southeast-1a, Ubuntu 24.04, 8 GB RAM, 2 vCPU, 160 GB disk | +| SSH identity on Harley's Mac | `~/.ssh/LightsailDefaultKey-ap-southeast-1.pem` | +| Application config | `/opt/multica/compose.yml`, `/opt/multica/.env` (root-only) | +| Running images | `ghcr.io/multica-ai/multica-backend:v0.4.43`, `ghcr.io/multica-ai/multica-web:v0.4.43` | +| Running backend commit | `2ae2dbbb8f9ed9ffe1739ecf5abfe31a940ee50c` | +| Database | `pgvector/pgvector:pg17`, migration `467_autopilot_trigger_creator_from_autopilot` | +| Persistent volumes | `multica_pgdata`, `multica_backend_uploads` | +| Reverse proxy | Caddy; `/etc/caddy/Caddyfile`; HTTPS to loopback ports 8188 and 3318 | +| Caddy backend routes | `/health`, `/readyz`, `/ws`, `/ws/*`, `/api/daemon/ws`; other requests go to frontend | +| Worker | `multica-worker.service`, OS user `multica-worker`, 5 GiB memory limit | +| Worker root | `/srv/multica-worker`; role homes under `runtime/` | +| Worker CLI profile | `/home/multica-worker/.multica/profiles/server/` | +| Database backups | `/opt/multica/backups`, `multica-backup.timer`, daily 23:55 UTC | +| Token renewal | `multica-github-tokens.timer` and `multica-internal-github-tokens.timer` | + +Readiness reported database and migrations OK. Caddy, Docker, worker and backup +timer were active. The latest database dump was 17 September at 23:55 UTC (about +1.2 MB). This is service verification, not a fresh login, Lark, or agent task test. + +Current release image digests, retained for identification: + +```text +backend: ghcr.io/multica-ai/multica-backend@sha256:5921256dff4d94b2ee60679534d24d62df82ac305eee13440f5aceaf91a3a769 +frontend: ghcr.io/multica-ai/multica-web@sha256:fc937fbbf8e5a87d166e5e1e1420acec38aea29cec93570006c2d528063dbd45 +``` + +## Read and change production settings + +```sh +ssh -i ~/.ssh/LightsailDefaultKey-ap-southeast-1.pem ubuntu@52.76.32.3 +sudo docker compose --project-directory /opt/multica -f /opt/multica/compose.yml ps +sudo systemctl is-active caddy docker multica-worker multica-backup.timer +curl -fsS https://multica.coderbase.dev/readyz +curl -fsS https://multica.coderbase.dev/health +``` + +Edit only the needed keys in `/opt/multica/.env`, after making a root-only dated +backup. Preserve JWT and integration encryption keys: replacing them can invalidate +sessions or make stored integration credentials unreadable. Validate without +printing interpolated secrets: + +```sh +sudo docker compose --project-directory /opt/multica -f /opt/multica/compose.yml config --quiet +sudo docker compose --project-directory /opt/multica -f /opt/multica/compose.yml up -d --no-deps backend +``` + +Compose `restart` alone does not apply changed environment values. Recreate only +the affected service, then verify readiness and the feature changed. Check active +agent work before interrupting backend or worker connections. If a setting is +missing from the installed Compose environment mapping, update that mapping too. +Keep root frontend and backend ports on loopback. Never run `down -v` here. + +Do not print `.env`, full Docker inspect/config output, private keys, runtime auth +files or dumps. To inspect environment key names, parse Docker's JSON environment +array and split each element once at `=`; line-based filtering leaks multiline PEM +values. Credential values remain host-side. Do not copy the historical deployment +folder wholesale: it contains secrets and database exports. + +The signup allowlist is separate from workspace invitations and agent invocation +access. A workspace invitation alone does not bypass instance signup policy. +Check current values on the host before changing access; older allowlists are stale. + +For CoderPush team onboarding, keep `ALLOWED_EMAIL_DOMAINS=coderpush.com` in +`/opt/multica/.env` and mapped into the backend Compose environment. Use the bare +domain, without `@` or a wildcard. Preserve `ALLOWED_EMAILS` for approved individual +exceptions outside that domain. Do not add each new CoderPush colleague to the +individual list: the domain policy covers them. This enables account signup; +workspace invitations and membership checks still apply. Recreate the backend +after policy changes and verify its effective environment, not just the file. + +## Deployment from this fork's main + +**Prepared locally; not activated in production.** Production still uses upstream +v0.4.43. There is no automatic main-to-server rollout. The fork checkout inspected +was `7e4758ac1a94e9ff843696333364610bb8d4bbf7`: 78 commits after v0.4.43, +with 32 new migrations, 468–499. Migration 468 deletes obsolete link rows and drops +columns; an image-only rollback is not sufficient after a schema upgrade. + +The fork workflow `.github/workflows/coderpush-images.yml` is manually dispatched +on `main` and publishes Linux AMD64 backend/frontend images tagged with the full +commit SHA. It uses the workflow's package token and needs no production SSH key. +It does not publish a moving `latest` tag or deploy anything. Both build jobs must +succeed for the same SHA; a partial publication is not a release. Existing CI must +also pass for that SHA. The workflow must first be committed and merged to `main`. + +1. Review the intended `main` commit, changes since the running version, migration + compatibility, and existing CI results. Build both images using **CoderPush main + images**, then record their registry digests. If the packages are private, set + up host pull access with a read-only package credential; never reuse agent GitHub + tokens or add a broad personal token to application configuration. +2. Rehearse migrations and application startup using a protected backup restored + to an isolated database, with Lark/Slack connectors, webhooks, outbound email, + schedulers and workers disabled. Do not start a second production connector. + Check migration success and compatibility with the installed desktop and daemon. +3. Before cutover, drain active work and stop the worker and backend. Take a fresh + database dump using `/usr/local/sbin/multica-backup`, check it with `pg_restore + --list`, and retain uploads, Compose, environment and worker configuration in + protected backup storage. The daily script backs up **only PostgreSQL**. Snapshot + scheduling was recorded previously but not verified in AWS today. A restore + rehearsal is still required; file presence is not restore proof. +4. Put `deploy/coderpush/images.yml` on the host as + `/opt/multica/coderpush-images.yml`. Set `CODERPUSH_BACKEND_IMAGE` and + `CODERPUSH_WEB_IMAGE` in the protected host `.env` to the reviewed digest + references. Keep the existing base Compose file, project name, ports and volumes. + Use both files for every subsequent Compose operation: + + ```sh + sudo docker compose --project-directory /opt/multica \ + -f /opt/multica/compose.yml -f /opt/multica/coderpush-images.yml config --quiet + sudo docker compose --project-directory /opt/multica \ + -f /opt/multica/compose.yml -f /opt/multica/coderpush-images.yml pull backend frontend + sudo docker compose --project-directory /opt/multica \ + -f /opt/multica/compose.yml -f /opt/multica/coderpush-images.yml up -d --no-deps backend frontend + ``` + +5. The backend entrypoint runs migrations before serving. Verify readiness, + `/health` commit, login, existing workspace/project data, desktop connections, + and runtime reconnects. Restart the worker when the API is healthy. Run an + explicitly authorized bounded agent/Lark test before declaring full acceptance. + Record the deployed SHA, both digests, backup identifier and results here. +6. If the upgraded schema is incompatible with the previous application, keep + writers stopped and restore the pre-upgrade database and matching application + images/configuration together. Account for any writes after cutover; do not + silently discard them. Do not assume down migrations recover deleted data. + +Build off the production host where practical: its worker shares the same CPU and +memory. Environment changes can remain independent of application releases. +Automatic deployment on every push is a separate decision after the first +controlled upgrade and recovery rehearsal. + +## Runtime and integration context + +The following context was recovered from earlier tasks. It is dated evidence, not +a claim that each integration was retested on 18 September. + +- The original local stack at `/Users/qron/Projects/coderpush-multica-selfhost` + migrated to Singapore on 15 September. The local backend/frontend and old daemon + were stopped; avoid reviving the old Lark websocket consumer. +- The historical deployment directory is + `/Users/qron/Projects/coderpush-multica-deployment`. Its README and bootstrap + artifacts are useful references but include stale instructions and private files. +- Worker commands use bubblewrap with separate role homes. The AppArmor profile + `/etc/apparmor.d/multica-bwrap` permits its user namespaces without disabling + Ubuntu's global restriction. Preserve the resolver mount when changing sandboxing. +- Web Lead, Web Engineer, Code Reviewer and SEO Analyst form the web squad in + workspace slug `coderpush-lark-pilot`. Web Lead coordinates; Engineer implements; + Reviewer reviews the PR head; SEO reads reporting data. Tokens are role-scoped + and renewed every 30 minutes. Running tasks can retain expired launch-time tokens. +- On 18 September all four web agents were changed to **Entire workspace** invocation + access. This supersedes the old owner-only pilot. Lark identity linking remains a + separate concern; do not infer another person's access from Harley's successful run. +- Web Lead can coordinate release after recorded authorized human approval; Engineer + rechecks the approved PR commit and checks, merges, verifies production, and returns + evidence. Anh may approve simple low-risk website changes after technical checks; + consequential technical decisions go to Harley. These rules belong to the web squad + and do not grant blanket merge/deploy authority for this Multica fork. +- Lark app version 0.1.2 was released on 18 September with display name **Web Lead** + and description “Connects the Web Factory squad to Lark. Bring it website and + marketing requests.” Preserve the existing international Lark installation. + The proposed separate Lark bridge was not delivered in the recovered history. +- GitHub server integration and execution credentials are separate. The original + `CoderPush/web` installation and sandbox grants were limited by role. Live host now + also has a CoderInternals token timer; inspect that setup before assuming the old + single-repository scope still describes the whole server. +- Resend was configured for login mail from `multica@coderbase.dev`; delivery was + verified on 15 September. Vercel MCP was authorized for Web Lead with broad OAuth + permission: project context and prompt rules do not make that credential read-only. +- SEO's separate Google service account has GA4 Viewer and Search Console Restricted + access; CLP-5 verified real reports on 15 September. Do not distribute its key to + other runtime homes. LinkedIn was not connected in that handover. +- “Online” only proves runtime connectivity. A later NanoHome setup task observed + Codex 401 authentication failure on Singapore. Model auth belongs to the runtime + OS user/home; refreshing the Mac's login does not repair server credentials. + +## Source tasks and outstanding work + +- `01a0a444-2fbe-7d10-ab7d-661b246f28cc` — **Update Multica Desktop server**: + local setup, hosting decision, migration, sandboxing, backup and integrations. +- `01a0b28f-9da5-7b60-a6c6-c45d52dac458` — **Set up Multica and Lark**: + latest web-agent access, approval handoff and Lark rename. +- `01a0adbe-0b3c-7cf0-bdbe-371a8ae36194` — **Investigate multica setup failure**: + Singapore model authentication diagnosis. + +Open items: first fork image build and deployment rehearsal; backup restore testing +and retention/off-host verification; reconcile newer CoderInternals/NanoHome runtime +configuration before changing shared services. Keep live runtime edits and repository +delivery status separate: a host change does not mean a PR was committed or merged. + +## Web runtime access — verified 20 September 2026 + +For CLP-15, the daemon's repository checkout had no Git credential even though +agent wrappers supplied role tokens. The daemon now uses +`/usr/local/bin/multica-web-git-credential` through the worker user's Git config. +The helper reads the renewed Web Lead token on each invocation and only answers +HTTPS credential requests for `github.com/CoderPush/web(.git)`. It supplies no +credential for other hosts or repositories. It uses the existing contents-read +grant; Engineer retains its separate contents-write token inside its runtime. +Web Lead subsequently verified `multica repo checkout` in an actual agent run. + +Harley approved Actions read-only for the existing CoderPush Multica GitHub App +installation. `/usr/local/sbin/multica-refresh-github-tokens` now requests that +permission for the three web role tokens, still restricted to CoderPush/web. +All three read the failed Actions run; Engineer's token also downloaded the log +archive. The installed `gh run view --log-failed` returned empty output with exit +0 for that run; the Actions run logs API returned the actual failure. Do not treat +empty CLI output as evidence of a clean run. + +Direct desktop/API chat messages do not create a Lark channel delivery record, +even in an existing channel-origin chat. To get a native Web Lead reply into its +original Lark topic, mention Web Lead in that topic. The CLP-15 topic trigger was +verified to create a delivery record for the correct topic. Do not inject database +delivery rows or give agents the server's integration secrets to work around this. + +## Worker capacity — verified 20 September 2026 + +At Harley's request, the shared Singapore worker now starts with +`--max-concurrent-tasks 2` in `/etc/systemd/system/multica-worker.service`. +The service was restarted after its running/dispatched/preparing queue was empty; +the replacement process and daemon status confirmed capacity 2 and healthy runtime +registration. The 5 GiB memory cap and TasksMax 512 remain unchanged. These are two +shared task slots across the daemon's workspaces, not a reserved coordinator slot. +The prior unit is backed up on the host as +`multica-worker.service.backup-20260920T071152Z`. + +The Web delivery monitor and backlog autopilot completed its first hourly cycle: +it verified PR #184 in production, reconciled stale backlog items, and dispatched +CLP-24, which produced PR #185. This verifies one monitoring-to-implementation +cycle; it does not yet demonstrate sustained two-task load or Anh-originated +runtime authorization. + +## Internal Apps Dependabot access — verified 20 September 2026 + +Harley approved Dependabot alerts read-only for the existing CoderPush Multica +GitHub App and accepted its installation update. The installation still selects +`CoderPush/internal` and `CoderPush/web`; no repositories were added. Installation +permissions are shared across those selected repositories, while runtime tokens +remain narrower. + +`/usr/local/sbin/multica-refresh-internal-github-tokens` now explicitly requests +`vulnerability_alerts=read` for Internal Apps Lead, Engineer and Reviewer, each +restricted to repository ID `1017468524` (`CoderPush/internal`). Other permission +fields and the web token generator were unchanged. The prior generator is saved +as `.backup-20260920T081928Z` alongside the script. All three refreshed tokens +returned only CoderPush/internal from the installation-repositories endpoint and +read all 150 open critical/high alerts (23 critical, 127 high) through cursor +pagination. The renewal timer remains active. Existing agent processes may retain +launch-time tokens until their next run; no worker restart was performed. + +Harley's Codex task also maintains hourly sanitized alert and domain-resolved +Vercel deployment evidence for CLP-17 using existing local authentication. Vercel +credentials were not copied into Multica. Collection does not establish alert +reachability or authorize remediation merges. The host access change is active; +this runbook update remains local and uncommitted. + +## Model and monitor usage settings — verified 23 September 2026 + +Harley requested current models with lower usage for CoderPush, NanoHome and +EO/Hark. Fifteen user-facing Codex agents were updated through the authenticated +Multica CLI and read back from production. Twelve now use `gpt-6-sol` with +`medium` reasoning. Code Reviewer and SEO Analyst use `gpt-6-luna` with `medium` +reasoning; Hark Release Watch uses `gpt-6-luna` with `low` reasoning. All fifteen +explicitly select standard service (`default`). NanoHome's six agents previously +had no model override; Hogan's local default was `gpt-6-astra`, so explicit agent +settings remove that inheritance. CoderPush's Claude-backed Mika, internal builder +agents, CoderFactory and TPS settings were not changed. + +Singapore's Codex CLI was upgraded from 0.153.4 to 0.156.0, matching the inspected +Hogan CLI. The complete prior tool installation is retained at +`/opt/multica-agent-tools.backup-model-upgrade-20260923`. The installed CLI's +`model/list` now includes GPT-6 Sol and Luna; Hogan's catalogue also includes both. +The agent settings backup is on Hogan at +`/Users/hogan/.multica/model-settings-backup-20260923T031139Z.json` (mode 0600). +No worker restart or additional model inference test was performed. Settings +apply to subsequent launches; existing runs were not interrupted. Catalogue and +saved-setting checks do not prove every runtime's authentication or task execution. +Internal Apps Lead had an existing provider-auth/access failure that this update +does not establish as resolved. + +At approximately 10:05 Vietnam time, the task table showed 41 NanoHome delivery +coordination launches since local midnight, including quota failures. Harley +selected hourly checks from 08:00 through 20:00 Vietnam time. The existing trigger +`ddcf0ed3-5fe1-4e8f-a2cd-b21014476ff9` on autopilot +`9c60c180-4fda-4450-ad48-96d389a9c033` now uses `0 8-20 * * *` with +`Asia/Ho_Chi_Minh`, replacing `*/15 * * * *`. The API returned its next run as +11:00 Vietnam time on 23 September. This reduces scheduled wakeups from 96 to 13 +per day (about 86%); it is not a measured token or subscription-usage saving. +The separate daily NanoHome audit and other autopilot schedules are unchanged. + +Production settings are active. This appended operations record is local and +uncommitted; it does not constitute a fork application release. + +## Web flow pilot — 23 September 2026 + +Harley approved agent-led readiness and end-to-end delivery for routine CoderPush +Web work within the existing fast lane. This is scoped to project +`1f065765-5630-4e67-9f16-adffaa6b3ce3` and Web Factory squad +`403d083c-e6ca-4afd-83f8-9aeca8fb41a9`. Exception approvals and explicit human +pauses remain binding. It does not extend to Internal Apps, NanoHome, EO or TPS. + +This supersedes the Web Lead and Code Reviewer model assignments above. Web Lead +now uses GPT-6 Luna at low effort, with one concurrent run. Web Triage +(`0e3b6e47-602e-4d51-9d56-1c3b9ba6a0f4`) uses GPT-6 Sol at medium effort, with +one concurrent run, on the existing Hogan Reviewer runtime. No credentials were +copied or runtime permissions changed. The new agent is workspace-invocable and +a Web Factory member. Engineer and substantive Code Reviewer use Sol/medium. + +The Lead routes clear work directly and calls Triage only for ambiguity or +incomplete briefs. Triage can approve routine scope and dispatch ready work +without another Lead or human approval. Issue descriptions hold the current +outcome, scope/acceptance, release lane, evidence, next owner/action and blocker. +Settled comment decisions are incorporated with source links; history is +preserved. Bookkeeping writes use `--no-start`, without notifying mentions. + +Use the existing columns: Backlog parks unselected/unready work; Todo means +ready; In Progress includes implementation, agent review and release verification; +In Review means a named human decision; Blocked names the dependency and owner; +Done requires verified acceptance. Production v0.4.43 dispatches on assignment +outside backlog or promotion out of backlog, not every column transition. +Comments can independently trigger work. These are configured operating rules, +not a new server-enforced readiness validator or universal column-trigger engine. +Check queued/running work before one intentional handoff; do not also mention or +start the same executor. Keep two delivery items active at most. Reuse one review +child per PR rather than creating one for each review attempt. + +The existing hourly Web delivery monitor was updated in place to inspect compact +summaries first, expand at most three changed/stalled items, and dispatch at most +one eligible backlog item per pass. It stops on unchanged blockers and does not +poll. The existing daily Board Hygiene sweep now samples at most five changed +web delivery issues and compares lead/triage overhead, duplicate runs/comments, +repeated review tickets and missing next owners. No schedule or recurring job was +added. Findings stay in run history unless a meaningful milestone or human action +requires notification. Instructions do not establish measured savings yet. + +Pre-change settings are retained on Hogan in +`/Users/hogan/.multica/web-flow-backup-20260923T032601Z.json` (0600); the new +agent ID and configuration receipt are in +`/Users/hogan/.multica/web-flow-pilot-20260923.json`. Settings were read back. +CLP-65 is the bounded initial triage verification: improve descriptions for +CLP-29, 54, 51, 63 and 64 without changing target status/assignee, starting work, +or posting target comments. Its native dispatch started task +`01a0cc4d-a408-7166-878e-38895d8c6aef`. That first run failed before making +changes: its isolated worker rejected GPT-6 Sol. The Hogan interactive CLI and shared tool installation were different versions. +The shared installation at `/Users/Shared/coderpush-worker-tools/npm` ran +0.153.4, but was not yet confirmed as the worker launch target. That installation was +backed up as `npm.backup-20260923-models` beside it and upgraded to 0.156.0. +One fresh verification run, `01a0cc50-17ff-7f12-8d3a-79f194ef26ae`, was then +queued. Do not infer role compatibility from the interactive user's catalogue. +The fresh run also failed with the same provider model-access rejection. No +further inference retries were dispatched. Singapore Web Lead's separate run +created at 03:12 UTC completed with usage attributed to `gpt-6-sol`; that proves +neither GPT-6 access nor repair for the isolated Hogan reviewer account. CLP-65 +has not completed its description cleanup. Temporary compatible models or a +dispatch pause were presented to Harley for a decision. + +Harley subsequently chose to keep GPT-6 and repair Hogan directly, preferring it +over Singapore for agent execution. Web Lead reasoning was raised from low to +high; Max is appropriate for selected hard decisions, not an automatic monitoring +default. Triage was briefly rebound to Singapore, but changing runtime cleared +its model override. That verification run (`01a0cc59-0e23-7f32-b306-5eda1471abc0`) +was cancelled; the model was restored explicitly. Triage is now back on the +Hogan Reviewer runtime with `gpt-6-sol`, `medium`, standard service. No successful +triage execution or issue cleanup has been verified yet. + +The earlier shared-install upgrade does not prove which executable the protected +Hogan wrapper launches. SSH as `hogan` cannot read `/Users/webworker` without +admin authentication. A read-only diagnostic is staged at +`/Users/hogan/.multica/diagnose-webworker-gpt6.py`; it reports wrapper path +references and model catalogue names without reading credentials. Its output +will be `/Users/hogan/.multica/webworker-gpt6-diagnostic.json`. Admin execution +was requested; no blanket sudo or filesystem permission change was made. + +Hogan's 10:40 Vietnam snapshot showed 16 GiB RAM, eight logical CPUs, load about +1.7 and 13 GiB disk available. Existing swap occupancy alone does not establish +current memory pressure. Keep the isolated worker's two-task limit; low free disk +is the observed capacity concern. Do not remove unrelated projects or caches +without a scoped cleanup decision. + +The completed privileged diagnostic resolved the worker executable to +`/Users/webworker/tools/npm/lib/node_modules/@openai/codex/bin/codex.js`. +The earlier shared-install update did not change this separate installation. +Engineer and Reviewer model caches dated 21 September lacked GPT-6 Sol/Luna. +A scoped updater is staged at `/Users/hogan/.multica/update-webworker-gpt6.py`: it +checks the resolved path and active worker processes, backs up the Codex package, +installs 0.156.0 as webworker, verifies the CLI version, and renames only the two +model caches for refresh. It preserves credentials, wrappers and service settings. +Execution still requires an administrator password; no repair or successful +GPT-6 inference is claimed until that execution and the bounded CLP-65 run verify. + +The administrator ran the scoped updater at 03:51 UTC on 23 September. Its +receipt `/Users/hogan/.multica/webworker-gpt6-update.json` confirms the actual +worker package changed from 0.153.4 to 0.156.0, with package and role model-cache +backups retained. CLP-65 verification run +`01a0cc64-3476-7e35-85fc-63e8c35add5e` started on the Hogan Reviewer runtime +and successfully produced model responses and Multica tool calls, clearing the +prior immediate model-access rejection. Final issue-update acceptance remains +pending until that bounded run finishes and its saved descriptions are checked. + +The verification run completed at 03:55 UTC without error; recorded usage names +`gpt-6-sol`. All five saved briefs were inspected. Two remaining description +errors were corrected directly with `--no-start`: CLP-28 plus its review child +counts as one delivery item, and a fast-lane review does not gain an additional +human approval gate merely because its retained status is in_review. CLP-65 was +closed after acceptance. The target issue statuses and assignees were preserved. +This verifies Sol on the actual Hogan Reviewer runtime; it is not a separate +Engineer-role or Luna inference test. No passwordless sudo grant was installed. + +## Model and brief refinement — 23 September 2026 + +All 16 Codex agents in CoderPush, NanoHome and EO have explicit GPT-6 overrides. +Coordination (Web/Internal/NanoHome Leads and NanoHome Mika) and Hark Release +Watch use Luna/high; SEO Analyst uses Luna/max. Engineering, QA, platform, +substantive code review, Hark and Web Triage retain Sol/medium. CoderPush Mika +remains on its separate Claude runtime. TPS and global interactive CLI defaults +were not changed. Settings were verified; this is not a new inference test of +every role. Active runs may retain their launch-time settings. + +Workspace skill `web-delivery` (`176b8ca3-b770-4667-8b72-7fdb8fa8989c`) is assigned +to Web Lead, Engineer, Reviewer, Triage and SEO. Source: [SKILL.md](skills/web-delivery/SKILL.md). +It centralizes concise ticket briefs, material-only comments, one intentional +handoff, parent/review capacity counting and fast-lane completion. Browser/CI +and Lark transport details live in on-demand references; duplicated blocks were +removed from Lead, Engineer and Reviewer prompts. Assignments and content were +read back; task-side loading awaits the next ordinary run. + +CLP-28's 667-word stale description was replaced with a current delivery brief, +preserving ticket-specific acceptance and links to history. GitHub production +verification run 35816856955 succeeded for 833e253ea1dd0767cb091beb983bd5af5714f5ed. +CLP-28 was closed with --no-start after saved-text verification. No new model run +was started for this bookkeeping change. Original settings/brief snapshots from +this refinement are retained in the operator's /tmp/multica-tune for this session. + +### CLP-28 handoff corrections + +The live web-delivery skill now includes an Engineer contract for concise PR/head +handoffs, delta review on the same review issue, checkpoint-based resumption, +independent blocker clearing, and pending-deployment follow-through without new +watchers. Read-back verified the content and reference on the existing skill. + +Local backend changes replace the child-completion instruction to create another +stage or always enter human review. Completion is now conditional on acceptance +and actual approval requirements. Agent mentions in a separate thread are refused +with the existing already_active outcome when the target is the active direct +assignee. Human follow-ups, same-thread supplements and other specialists retain +their routing. This admission check covers the observed sequential duplicate; +it is not a global atomic deduplication mechanism for simultaneous new threads. +The narrow v0.4.43 backport was deployed at 04:49:52 UTC on 23 September. +See [the rollout receipt](agent-handoff-rollout.md) for commit, checks and rollback. +Schema remains at 467; upgrading to current main still requires the full migration +procedure above. + +Verification: the database-backed routing/stage regression tests pass with the +race detector, including human messages, same-thread supplements, specialist +mentions, completed assignees and deliberate self-continuations. The full handler +suite still fails in the unchanged clock-skew fallback test +`TestReportTaskMessagesFallsBackWholeBatchForClockSkew` (database timestamp falls +about a millisecond before the asserted window). The first review caught an +over-broad self-continuation refusal; the guard now applies only to a different +agent coordinating the assignee. No schema or API enum changes were needed. + +## CoderPush signup domain — 24 September 2026 + +At 03:30 UTC, enabled `ALLOWED_EMAIL_DOMAINS=coderpush.com` on the production +backend. The previous domain list was empty, so only seven individually listed +addresses could register. All seven existing `ALLOWED_EMAILS` entries and +`ALLOW_SIGNUP=true` were preserved. Future CoderPush invitees need no individual +server allowlist edit; other domains still require explicit exceptions. + +Root-only environment backup: +`/opt/multica/.env.before-coderpush-domain-20260924T032752Z`. +Compose validation passed. Active/queued tasks reached zero before recreating +only the backend, using the same `multica-backend:handoff-013385718` image. +The running environment was read back. Public health reported commit +`013385718f731be728fd7b2c0d09828edb868009`; readiness reported database and +migrations OK; the worker service remained active. + +Eight isolated Go policy checks against the signup-gate functions extracted from +that production commit passed: new CoderPush addresses, uppercase domain, +existing NanoHome exceptions, unapproved domains, subdomains and suffix lookalikes. +These were policy checks, not the database-backed handler suite. No login email +was sent or person impersonated; a newly invited person's completed login and +workspace acceptance remain unverified. No application code or image changed. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 6b000a1fa40..0735b1a7150 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -51,6 +51,7 @@ export class TestApiClient { private workspaceId: string | null = null; private email: string | null = null; private createdIssueIds: string[] = []; + private createdProjectIds: string[] = []; private seededIssueIds: string[] = []; async login(email: string, name: string) { @@ -184,6 +185,35 @@ export class TestApiClient { } } + /** Create a project and register it for cleanup. */ + async createProject(title: string, opts?: Record) { + const res = await this.authedFetch("/api/projects", { + method: "POST", + body: JSON.stringify({ title, ...opts }), + }); + if (!res.ok) { + throw new Error(`create project failed: ${res.status} ${await res.text()}`); + } + const project = await res.json(); + this.createdProjectIds.push(project.id); + return project as { id: string; title: string; status: string }; + } + + async updateProject(id: string, updates: Record) { + const res = await this.authedFetch(`/api/projects/${id}`, { + method: "PUT", + body: JSON.stringify(updates), + }); + if (!res.ok) { + throw new Error(`update project failed: ${res.status} ${await res.text()}`); + } + return res.json(); + } + + async deleteProject(id: string) { + await this.authedFetch(`/api/projects/${id}`, { method: "DELETE" }); + } + async createIssue(title: string, opts?: Record) { const res = await this.authedFetch("/api/issues", { method: "POST", @@ -339,6 +369,16 @@ export class TestApiClient { } } this.createdIssueIds = []; + // Projects last: an issue delete leaves no project reference behind, and + // dropping the project first would strand the issues in the list. + for (const id of this.createdProjectIds) { + try { + await this.deleteProject(id); + } catch { + /* ignore — may already be deleted */ + } + } + this.createdProjectIds = []; } getToken() { diff --git a/e2e/issue-duplicate.spec.ts b/e2e/issue-duplicate.spec.ts new file mode 100644 index 00000000000..5304105ba9e --- /dev/null +++ b/e2e/issue-duplicate.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "@playwright/test"; +import { loginAsDefault, createTestApi } from "./helpers"; +import type { TestApiClient } from "./fixtures"; + +// Mark as duplicate (MUL-7349): the status picker's action cancels an issue +// and links it to its original; the original lists it; reopening removes it. +test.describe("Mark as duplicate", () => { + let api: TestApiClient; + let slug: string; + + test.beforeEach(async ({ page }) => { + api = await createTestApi(); + slug = await loginAsDefault(page); + }); + + test.afterEach(async () => { + await api.cleanup(); + }); + + test("marks, links both sides, and unmarks", async ({ page }) => { + const run = Date.now().toString(36); + const originalTitle = `Tap targets too small ${run}`; + const original = await api.createIssue(originalTitle, { status: "in_progress" }); + const duplicateTitle = `Status picker hard to tap ${run}`; + const duplicate = await api.createIssue(duplicateTitle, { status: "todo" }); + + await page.goto(`/${slug}/issues/${duplicate.id}`); + // The detail page can close a just-opened picker while it finishes + // loading, so retry opening until the action takes the click. + await expect(async () => { + await page.getByRole("button", { name: "Todo", exact: true }).first().click(); + await page.getByRole("button", { name: "Mark as duplicate" }).click({ timeout: 2000 }); + }).toPass(); + + const picker = page.getByRole("dialog"); + await picker.getByPlaceholder("Search issues...").fill(originalTitle); + await picker.getByText(originalTitle).click(); + + const originalLink = page.getByRole("link", { + name: `${original.identifier} ${originalTitle}`, + }); + await expect(originalLink).toBeVisible(); + await expect(page.getByRole("button", { name: "Cancelled", exact: true }).first()).toBeVisible(); + // The mark is logged, with the original linked by its bare identifier. + await expect(page.getByText(`marked this issue as a duplicate of ${original.identifier}`)).toBeVisible(); + await expect(page.getByRole("link", { name: original.identifier, exact: true })).toBeVisible(); + + await originalLink.click(); + await expect(page).toHaveURL(new RegExp(`/issues/${original.id}$`)); + await expect(page.getByRole("button", { name: "Duplicates" })).toBeVisible(); + // The sidebar row names the duplicate with its title; the activity feed + // links it by identifier alone. + await expect(page.getByRole("link", { name: `${duplicate.identifier} ${duplicateTitle}` })).toBeVisible(); + await expect(page.getByText(`marked ${duplicate.identifier} as a duplicate of this issue`)).toBeVisible(); + + await page.goto(`/${slug}/issues/${duplicate.id}`); + await page.getByRole("button", { name: "Not a duplicate" }).click(); + await expect(page.getByRole("button", { name: "Not a duplicate" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Todo", exact: true }).first()).toBeVisible(); + await expect(page.getByText(`unmarked this issue as a duplicate of ${original.identifier}`)).toBeVisible(); + }); +}); diff --git a/e2e/issue-project-status-filter.spec.ts b/e2e/issue-project-status-filter.spec.ts new file mode 100644 index 00000000000..b2760560b7f --- /dev/null +++ b/e2e/issue-project-status-filter.spec.ts @@ -0,0 +1,103 @@ +import { expect, test, type Page } from "@playwright/test"; +import { createTestApi, loginAsDefault } from "./helpers"; +import type { TestApiClient } from "./fixtures"; + +// "Project status" is a filter dimension of its own next to +// "Project": pick In Progress once instead of ticking every active project. +// The list surface is server-driven, so this is the only place the whole +// chain — menu → store → table query → SQL predicate — is exercised together. + +async function openProjectStatusMenu(page: Page) { + await page.getByRole("button", { name: "Filter", exact: true }).click(); + await page.getByRole("menuitem", { name: "Project status" }).click(); +} + +async function visibleIssueTitles(page: Page, titles: string[]) { + const present: string[] = []; + for (const title of titles) { + if (await page.getByText(title, { exact: true }).first().isVisible()) { + present.push(title); + } + } + return present.sort(); +} + +test.describe("Issue filter: project status", () => { + let api: TestApiClient; + const suffix = Date.now().toString(36); + const activeIssue = `pstatus active ${suffix}`; + const plannedIssue = `pstatus planned ${suffix}`; + const orphanIssue = `pstatus no project ${suffix}`; + const all = [activeIssue, plannedIssue, orphanIssue]; + + let plannedProjectId: string; + + test.beforeEach(async ({ page }) => { + api = await createTestApi(); + const activeProject = await api.createProject(`pstatus active ${suffix}`, { + status: "in_progress", + }); + const plannedProject = await api.createProject(`pstatus planned ${suffix}`, { + status: "planned", + }); + plannedProjectId = plannedProject.id; + await api.createIssue(activeIssue, { project_id: activeProject.id }); + await api.createIssue(plannedIssue, { project_id: plannedProject.id }); + await api.createIssue(orphanIssue); + await loginAsDefault(page); + }); + + test.afterEach(async () => { + await api.cleanup(); + }); + + test("narrows the list to issues whose project has the selected status", async ({ + page, + }) => { + await expect + .poll(() => visibleIssueTitles(page, all)) + .toEqual([...all].sort()); + + await openProjectStatusMenu(page); + const inProgress = page.getByRole("menuitemcheckbox", { name: "In Progress" }); + await inProgress.click(); + // Escape closes the sub-menu, then the root menu. Both have to go before + // the chips bar underneath is clickable again. + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + await expect(inProgress).toBeHidden(); + + // Only the issue in the in_progress project survives. The projectless + // issue is out too: with no project there is no status to match. + await expect.poll(() => visibleIssueTitles(page, all)).toEqual([activeIssue]); + + // The chip reports the dimension, and removing it restores the list. + const chipsBar = page.getByRole("main"); + await expect(chipsBar.getByText("Project status")).toBeVisible(); + await chipsBar.getByRole("button", { name: /Remove .*filter/ }).first().click(); + await expect + .poll(() => visibleIssueTitles(page, all)) + .toEqual([...all].sort()); + }); + + // The issue payloads do not change when a PROJECT's status does, so only a + // cache invalidation can refresh a window filtered on it — the global + // staleTime is Infinity. Without one the list stays stale until reload. + test("picks up a project that moves into the selected status", async ({ + page, + }) => { + await openProjectStatusMenu(page); + const inProgress = page.getByRole("menuitemcheckbox", { name: "In Progress" }); + await inProgress.click(); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + await expect(inProgress).toBeHidden(); + await expect.poll(() => visibleIssueTitles(page, all)).toEqual([activeIssue]); + + await api.updateProject(plannedProjectId, { status: "in_progress" }); + + await expect + .poll(() => visibleIssueTitles(page, all), { timeout: 15000 }) + .toEqual([activeIssue, plannedIssue].sort()); + }); +}); diff --git a/e2e/perf/typing-under-live-runs.spec.ts b/e2e/perf/typing-under-live-runs.spec.ts index 28f557568e9..c47407dedf6 100644 --- a/e2e/perf/typing-under-live-runs.spec.ts +++ b/e2e/perf/typing-under-live-runs.spec.ts @@ -110,6 +110,7 @@ function responseFor(pathname: string): unknown | undefined { case `/api/issues/${fx.ISSUE_ID}/timeline`: return fx.buildTimelineEntries(); case `/api/issues/${fx.ISSUE_ID}/task-runs`: return fx.buildTaskRuns(); case `/api/issues/${fx.ISSUE_ID}/subscribers`: + case `/api/issues/${fx.ISSUE_ID}/wakeups`: case `/api/issues/${fx.ISSUE_ID}/attachments`: return []; case `/api/issues/${fx.ISSUE_ID}/labels`: return { labels: [] }; case `/api/issues/${fx.ISSUE_ID}/children`: return { issues: [] }; diff --git a/e2e/project-resource-ref.spec.ts b/e2e/project-resource-ref.spec.ts new file mode 100644 index 00000000000..da903e2fe6a --- /dev/null +++ b/e2e/project-resource-ref.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "@playwright/test"; +import { loginAsDefault, waitForPageText } from "./helpers"; + +const REPO = "https://github.com/multica-ai/multica"; + +/** + * The checkout ref of a github_repo project resource, end to end. + * + * Covers what only the full stack can show: the ref survives project creation, + * comes back on the project page, and an edit persists — the server replaces + * resource_ref wholesale rather than deep-merging, so a payload that drops the + * URL is a class of bug the component tests alone cannot see. + */ +test("pins, edits and clears a repository's checkout ref", async ({ page }) => { + const slug = await loginAsDefault(page); + + await page.goto(`/${slug}/projects`, { waitUntil: "domcontentloaded" }); + await waitForPageText(page, "Projects"); + await page.getByRole("button", { name: /new project/i }).first().click(); + + // TitleEditor is a contenteditable, not an with a placeholder attr. + await page.getByRole("textbox", { name: /project title/i }).fill("Release line"); + await page.getByRole("button", { name: /repos/i }).first().click(); + await page.getByPlaceholder(/github\.com\/owner\/repo/i).fill(REPO); + await page.getByLabel(/starting branch/i).fill("release/2026-09"); + await page.getByRole("button", { name: /^add$/i }).click(); + await page.getByRole("button", { name: /^create project$/i }).click(); + + await waitForPageText(page, "Release line"); + await expect(page.getByText("release/2026-09")).toBeVisible({ timeout: 15000 }); + + // Editing an attached resource — the affordance the UI never had. + await page.getByTitle(/change the branch tasks work on/i).first().click(); + await expect(page.getByText(/which branch should tasks work on/i)).toBeVisible(); + + // A ref git could not resolve is refused before it is stored, rather than + // failing minutes later inside a task with a repo-cache error. + await page.getByLabel(/starting branch/i).fill("main..dev"); + await expect(page.getByRole("button", { name: /^save$/i })).toBeDisabled(); + + await page.getByLabel(/starting branch/i).fill("v1.4.0"); + await page.getByRole("button", { name: /^save$/i }).click(); + await expect(page.getByText("v1.4.0")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("release/2026-09")).toHaveCount(0); + + // The saved value survives a reload — i.e. it reached the database, and the + // URL rode along with it rather than being replaced away. + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByText("v1.4.0")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("multica-ai/multica")).toBeVisible(); + + // Clearing goes back to the repository's default branch. + await page.getByTitle(/change the branch tasks work on/i).first().click(); + await page.getByLabel(/starting branch/i).fill(""); + await page.getByRole("button", { name: /^save$/i }).click(); + await expect(page.getByText("v1.4.0")).toHaveCount(0, { timeout: 10000 }); + await expect(page.getByText("multica-ai/multica")).toBeVisible(); + // Not an empty row: an unpinned repo says which branch it uses, so clearing + // is confirmable rather than indistinguishable from the setting not existing. + // Exact text, because the success toast also says "Back to the default branch". + await expect(page.getByText("Default branch", { exact: true })).toBeVisible(); +}); diff --git a/e2e/shimmer-text.spec.ts b/e2e/shimmer-text.spec.ts new file mode 100644 index 00000000000..73dc141ea51 --- /dev/null +++ b/e2e/shimmer-text.spec.ts @@ -0,0 +1,106 @@ +import { readFileSync } from "node:fs"; +import { expect, test, type Page } from "@playwright/test"; +import { shimmerTextScript } from "../packages/ui/test/shimmer-text-bundle.mjs"; + +// No server required. Use the production markup/styles: jsdom cannot check +// counter-translated glyph alignment, text selection, or media fallbacks. +const css = readFileSync("packages/ui/styles/base.css", "utf8"); + +declare global { + interface Window { + shimmerFixture: { render(text: string, active: boolean): void }; + } +} + +test.beforeEach(async ({ page }) => { + await page.setContent(`
`); + await page.addScriptTag({ content: shimmerTextScript }); +}); + +async function renderLabel(page: Page, text = "Working", active = true) { + await page.evaluate(({ text, active }) => window.shimmerFixture.render(text, active), { text, active }); +} + +test("exposes and copies the label once, including localized and updated counts", async ({ page }) => { + for (const text of ["Working", "2 agents working", "12 个智能体正在工作"]) { + await renderLabel(page, text); + expect(await page.evaluate(() => document.getAnimations().filter((a) => a.playState === "running").length)).toBe(2); + expect(await page.locator("#fixture").ariaSnapshot()).toBe(`- text: ${text}`); + const selected = await page.locator("#label").evaluate((label) => { + const range = document.createRange(); + range.selectNodeContents(label); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + return selection.toString(); + }); + expect(selected).toBe(text); + } +}); + +test("keeps both copies aligned throughout the sweep and truncates long labels", async ({ page }) => { + for (const text of ["Working", "12 个智能体正在工作", "Searching through a very long project name"]) { + await renderLabel(page, text); + // Include both sides of the repeating sweep's seam. + for (const time of [0, 400, 1250, 2499, 2501]) { + const geometry = await page.evaluate((time) => { + for (const animation of document.getAnimations()) { + animation.pause(); + animation.currentTime = time; + } + const label = document.querySelector("#label")!; + const copy = label.querySelector(".shimmer-text-copy")!; + const baseRect = label.getBoundingClientRect(); + const copyRect = copy.getBoundingClientRect(); + return { + dx: Math.abs(baseRect.x - copyRect.x), + dy: Math.abs(baseRect.y - copyRect.y), + dw: Math.abs(baseRect.width - copyRect.width), + width: baseRect.width, + overflow: getComputedStyle(label).textOverflow, + copyOverflow: getComputedStyle(copy).textOverflow, + clipped: label.scrollWidth > label.clientWidth, + }; + }, time); + expect(geometry.dx).toBeLessThan(0.1); + expect(geometry.dy).toBeLessThan(0.1); + expect(geometry.dw).toBeLessThan(0.1); + expect(geometry.width).toBeLessThanOrEqual(180); + expect(geometry.overflow).toBe("ellipsis"); + expect(geometry.copyOverflow).toBe("ellipsis"); + if (text.startsWith("Searching")) expect(geometry.clipped).toBe(true); + } + } +}); + +test("remains readable without running animations when inactive or motion is reduced", async ({ page }) => { + await renderLabel(page, "Queued", false); + await expect(page.locator("#label")).toHaveText("Queued"); + expect(await page.evaluate(() => document.getAnimations().length)).toBe(0); + + await page.emulateMedia({ reducedMotion: "reduce" }); + await renderLabel(page); + await expect(page.locator("#label")).toHaveCSS("color", "rgb(102, 102, 102)"); + await expect(page.locator(".shimmer-text-window")).toBeHidden(); + expect(await page.evaluate(() => document.getAnimations().length)).toBe(0); +}); + +test("uses readable system text and no highlight in forced colors", async ({ page }) => { + await page.emulateMedia({ forcedColors: "active" }); + await renderLabel(page); + await expect(page.locator(".shimmer-text-window")).toBeHidden(); + expect(await page.evaluate(() => document.getAnimations().length)).toBe(0); + expect(await page.locator("#fixture").ariaSnapshot()).toBe("- text: Working"); + const colors = await page.locator("#label").evaluate((label) => { + const probe = document.createElement("span"); + probe.style.color = "CanvasText"; + document.body.append(probe); + const result = [getComputedStyle(label).color, getComputedStyle(probe).color]; + probe.remove(); + return result; + }); + expect(colors[0]).toBe(colors[1]); +}); diff --git a/packages/core/api/client.test.ts b/packages/core/api/client.test.ts index bdf3bb3f0ec..5c91bf1f358 100644 --- a/packages/core/api/client.test.ts +++ b/packages/core/api/client.test.ts @@ -219,7 +219,52 @@ describe("ApiClient pull-request response schema", () => { await expect( new ApiClient("https://api.example.test").listIssuePullRequests("issue-1"), - ).resolves.toEqual({ pull_requests: [] }); + ).resolves.toEqual({ pull_requests: [], auto_complete: null }); + }); + + function stubPullRequests(body: unknown) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + } + + it("parses the auto-complete decision and link source", async () => { + stubPullRequests({ + pull_requests: [{ ...validPR, link_source: "title" }], + auto_complete: { state: "waiting", pull_request_ids: ["pr-1"], issue_disabled: false, workspace_enabled: true }, + }); + const result = await new ApiClient("https://api.example.test").listIssuePullRequests("issue-1"); + expect(result.pull_requests[0]?.link_source).toBe("title"); + expect(result.auto_complete).toEqual({ + state: "waiting", + pull_request_ids: ["pr-1"], + issue_disabled: false, + workspace_enabled: true, + }); + }); + + it("treats a missing auto-complete block (older backend) as null", async () => { + stubPullRequests({ pull_requests: [validPR] }); + const result = await new ApiClient("https://api.example.test").listIssuePullRequests("issue-1"); + expect(result.auto_complete).toBeNull(); + expect(result.pull_requests).toHaveLength(1); + }); + + it("keeps the PR list when only the auto-complete block or link source is malformed", async () => { + stubPullRequests({ + pull_requests: [{ ...validPR, link_source: "psychic" }], + auto_complete: { state: 42 }, + }); + const result = await new ApiClient("https://api.example.test").listIssuePullRequests("issue-1"); + expect(result.auto_complete).toBeNull(); + expect(result.pull_requests).toHaveLength(1); + expect(result.pull_requests[0]?.link_source).toBeUndefined(); }); }); diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index edbba008c8b..a53f706d3ff 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -1,3 +1,6 @@ +import type { IssueWakeup, IssueWakeupSummaryRow } from "../types/issue-wakeup"; +import type { WorkspaceWakeupPage, WorkspaceWakeupFilters } from "../types/issue-wakeup"; +import { WorkspaceWakeupPageSchema, IssueWakeupSchema, IssueWakeupSummaryRowSchema } from "./schemas"; import type { InboxFilters } from "../inbox/filter-store"; import type { ArchivedInboxPage, ArchivedInboxFacets } from "../types/inbox"; import { configStore } from "../config"; @@ -7,6 +10,7 @@ import type { CreateIssueRequest, MoveIssueRequest, UpdateIssueRequest, + IssueDuplicates, GroupedIssuesResponse, ListIssuesResponse, SearchIssuesResponse, @@ -164,7 +168,7 @@ import type { PluginPreviewRequest, PluginInstallRequest, PluginConfigRequest, - GitHubPullRequest, + IssuePullRequestsResponse, ListGitHubInstallationsResponse, ListGitHubRepositoriesResponse, GitHubConnectResponse, @@ -240,6 +244,8 @@ import { createRequestId, createSafeId } from "../utils"; import { getCurrentSlug } from "../platform/workspace-storage"; import { parseWithFallback } from "./schema"; import { + RuntimeProfileSchema, + RuntimeProfileListSchema, AgentTaskListSchema, AgentActivityBucketListSchema, AttachmentResponseSchema, @@ -254,6 +260,7 @@ import { SendChatMessageResponseSchema, StartMikaOnboardingResponseSchema, ChildIssuesResponseSchema, + IssueDuplicatesResponseSchema, ChildIssueProgressResponseSchema, CommentsListSchema, CommentTriggerPreviewSchema, @@ -542,6 +549,30 @@ function assertAgentConversationStartersWriteSupported(data: { } } +function requestedIssueCreateProperties( + data: CreateIssueRequest, +): NonNullable | undefined { + const properties = data.properties; + return properties && Object.keys(properties).length > 0 ? properties : undefined; +} + +function assertIssueCreatePropertiesSnapshot( + requested: NonNullable | undefined, + issue: Issue, +): void { + if (!requested) return; + for (const propertyId of Object.keys(requested)) { + // The server may canonicalize a valid request (trim a URL, order and + // de-duplicate a multi-select, normalize an actor UUID). Presence is the + // integrity signal here; IssueSchema has already validated the value type. + if (!Object.prototype.hasOwnProperty.call(issue.properties, propertyId)) { + throw new Error( + `Issue ${issue.identifier || issue.id} was created, but the server did not confirm its custom properties. Review the issue before retrying.`, + ); + } + } +} + // errorCode extracts the stable `code` a handler attaches to a failure // (writeErrorCode), so a caller can render its own localized sentence instead // of toasting the server's English one. Returns undefined for a non-ApiError, @@ -1210,8 +1241,8 @@ export class ApiClient { * unique `(workspace_id, number)` index, and 404s on a wrong prefix or a * missing number. * - * `signal` is optional so cancel-on-unmount callers (identifier autolink - * resolution) can abort an in-flight lookup the same way search does. + * `signal` remains optional for callers that need to abort an in-flight + * lookup; identifier autolink resolution intentionally lets it complete. * * The 2xx body is validated, not cast. A single issue is not a list: there * is no safe-empty shape to degrade to, and the identifier-autolink caller @@ -1221,6 +1252,40 @@ export class ApiClient { * an ApiError 404, so `issueIdentifierOptions` propagates it instead of * caching it as "no such issue". */ + async listWorkspaceWakeups(filters: WorkspaceWakeupFilters): Promise { + const params = new URLSearchParams(Object.entries(filters).map(([key, value]) => [key, String(value)])); + const raw = await this.fetch(`/api/issue-wakeups?${params}`); + const parsed = parseWithFallback(raw, WorkspaceWakeupPageSchema, null, { endpoint: "GET /api/issue-wakeups" }); + if (!parsed) throw new Error("Could not load workspace wakeups"); + return parsed; + } + + async listIssueWakeups(issueId: string): Promise { + const raw = await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups`); + const parsed = parseWithFallback(raw, IssueWakeupSchema.array(), null, { endpoint: "GET /api/issues/:id/wakeups" }); + if (!parsed) throw new Error("Could not load wakeups"); + return parsed; + } + + async listIssueWakeupSummaries(): Promise { + const raw = await this.fetch("/api/issue-wakeup-summaries"); + const parsed = parseWithFallback(raw, IssueWakeupSummaryRowSchema.array(), null, { endpoint: "GET /api/issue-wakeup-summaries" }); + if (!parsed) throw new Error("Could not load wakeup summaries"); + return parsed; + } + + async enableIssueWakeup(issueId: string, wakeupId: string, input: { revision: number; at?: string; rearm?: boolean }): Promise { + await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups/${encodeURIComponent(wakeupId)}/enable`, { method: "POST", body: JSON.stringify(input) }); + } + + async editIssueWakeupInstruction(issueId: string, wakeupId: string, input: { instruction: string; expected_instruction: string; revision: number }): Promise { + await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups/${encodeURIComponent(wakeupId)}/instruction`, { method: "PATCH", body: JSON.stringify(input) }); + } + + async disableIssueWakeup(issueId: string, wakeupId: string): Promise { + await this.fetch(`/api/issues/${encodeURIComponent(issueId)}/wakeups/${encodeURIComponent(wakeupId)}/disable`, { method: "POST" }); + } + async getIssue(id: string, options?: { signal?: AbortSignal }): Promise { const raw = await this.fetch( `/api/issues/${encodeURIComponent(id)}`, @@ -1237,6 +1302,15 @@ export class ApiClient { } async createIssue(data: CreateIssueRequest): Promise { + const requestedProperties = requestedIssueCreateProperties(data); + if (requestedProperties) { + const config = await this.getConfig(); + if (config.issue_create_properties_supported !== true) { + throw new Error( + "This server version does not support atomic custom properties on issue creation. Update the server before creating this issue.", + ); + } + } // Parse through a schema (not a raw cast): the create modal keys its // label-attach compatibility fallback off `labels` being absent vs a // validated Label[], so an unvalidated wrong shape must not slip through. @@ -1256,6 +1330,7 @@ export class ApiClient { if (!issue) { throw new Error(); } + assertIssueCreatePropertiesSnapshot(requestedProperties, issue); return issue; } @@ -1300,6 +1375,16 @@ export class ApiClient { data: CreateCommentSubIssueRequest, ): Promise { try { + const requestedProperties = + data.mode === "manual" ? requestedIssueCreateProperties(data.issue) : undefined; + if (requestedProperties) { + const config = await this.getConfig(); + if (config.issue_create_properties_supported !== true) { + throw new Error( + "This server version does not support atomic custom properties on issue creation. Update the server before creating this issue.", + ); + } + } const raw = await this.fetch(`/api/comments/${anchorCommentId}/sub-issues`, { method: "POST", body: JSON.stringify(data), @@ -1309,6 +1394,7 @@ export class ApiClient { endpoint: "POST /api/comments/:id/sub-issues (manual)", }); if (!issue) throw new Error("Invalid sub-issue response"); + assertIssueCreatePropertiesSnapshot(requestedProperties, issue); return issue; } const task = parseWithFallback<{ task_id: string } | null>( @@ -1369,6 +1455,16 @@ export class ApiClient { }); } + async listIssueDuplicates(id: string): Promise { + const raw = await this.fetch(`/api/issues/${id}/duplicates`); + return parseWithFallback( + raw, + IssueDuplicatesResponseSchema, + { duplicate_of: null, duplicates: [] }, + { endpoint: "GET /api/issues/:id/duplicates" }, + ); + } + async listChildIssues(id: string): Promise<{ issues: Issue[] }> { const raw = await this.fetch(`/api/issues/${id}/children`); return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, { @@ -2184,25 +2280,39 @@ export class ApiClient { const res = await this.fetch<{ runtime_profiles?: RuntimeProfile[] }>( `/api/workspaces/${workspaceId}/runtime-profiles`, ); - return res.runtime_profiles ?? []; + return parseWithFallback( + res.runtime_profiles ?? [], + RuntimeProfileListSchema, + [] as RuntimeProfile[], + { endpoint: "listRuntimeProfiles" }, + ); } async getRuntimeProfile( workspaceId: string, profileId: string, ): Promise { - return this.fetch( + const result = await this.fetch( `/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`, ); + return parseWithFallback(result, RuntimeProfileSchema, result, { + endpoint: "runtimeProfile", + }); } async createRuntimeProfile( workspaceId: string, body: CreateRuntimeProfileRequest, ): Promise { - return this.fetch(`/api/workspaces/${workspaceId}/runtime-profiles`, { - method: "POST", - body: JSON.stringify(body), + const result = await this.fetch( + `/api/workspaces/${workspaceId}/runtime-profiles`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); + return parseWithFallback(result, RuntimeProfileSchema, result, { + endpoint: "runtimeProfile", }); } @@ -2211,13 +2321,16 @@ export class ApiClient { profileId: string, patch: UpdateRuntimeProfileRequest, ): Promise { - return this.fetch( + const result = await this.fetch( `/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`, { method: "PATCH", body: JSON.stringify(patch), }, ); + return parseWithFallback(result, RuntimeProfileSchema, result, { + endpoint: "runtimeProfile", + }); } async deleteRuntimeProfile( @@ -2579,6 +2692,24 @@ export class ApiClient { }); } + async createTaskSupplement(issueId: string, taskId: string, content: string, clientRequestId: string): Promise { + const raw = await this.fetch(`/api/issues/${issueId}/tasks/${taskId}/supplements`, { + method: "POST", + body: JSON.stringify({ content, client_request_id: clientRequestId }), + }); + const comment = parseWithFallback(raw, CommentSchema, EMPTY_COMMENT, { + endpoint: "POST /api/issues/:id/tasks/:taskId/supplements", + }); + if (!comment.id) throw new Error("Invalid additional-message response"); + return comment; + } + + async retryTaskSupplement(issueId: string, taskId: string, commentId: string): Promise { + await this.fetch(`/api/issues/${issueId}/tasks/${taskId}/supplements/${commentId}/retry`, { + method: "POST", + }); + } + async getIssueUsage(issueId: string): Promise { return this.fetch(`/api/issues/${issueId}/usage`); } @@ -4571,7 +4702,7 @@ export class ApiClient { }); } - async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> { + async listIssuePullRequests(issueId: string): Promise { const raw = await this.fetch(`/api/issues/${issueId}/pull-requests`); return parseWithFallback( raw, @@ -4581,6 +4712,51 @@ export class ApiClient { ); } + /** Link a PR the workspace already mirrors, by pasted URL or by id (undo). */ + async linkIssuePullRequest( + issueId: string, + body: { url: string } | { pull_request_id: string }, + ): Promise { + const raw = await this.fetch(`/api/issues/${issueId}/pull-requests`, { + method: "POST", + body: JSON.stringify(body), + }); + return parseWithFallback( + raw, + IssuePullRequestsResponseSchema, + EMPTY_ISSUE_PULL_REQUESTS_RESPONSE, + { endpoint: "POST /api/issues/:id/pull-requests" }, + ); + } + + /** Remove a PR from an issue; later webhooks will not link it again. */ + async unlinkIssuePullRequest(issueId: string, pullRequestId: string): Promise { + const raw = await this.fetch( + `/api/issues/${issueId}/pull-requests/${pullRequestId}`, + { method: "DELETE" }, + ); + return parseWithFallback( + raw, + IssuePullRequestsResponseSchema, + EMPTY_ISSUE_PULL_REQUESTS_RESPONSE, + { endpoint: "DELETE /api/issues/:id/pull-requests/:prId" }, + ); + } + + /** Turn PR auto-complete off (or back on) for one issue. */ + async setIssuePRAutoComplete(issueId: string, disabled: boolean): Promise { + const raw = await this.fetch(`/api/issues/${issueId}/pr-auto-complete`, { + method: "PUT", + body: JSON.stringify({ disabled }), + }); + return parseWithFallback( + raw, + IssuePullRequestsResponseSchema, + EMPTY_ISSUE_PULL_REQUESTS_RESPONSE, + { endpoint: "PUT /api/issues/:id/pr-auto-complete" }, + ); + } + // VCS integration (Forgejo / Gitea / GitLab) async listVCSConnections(workspaceId: string): Promise { return this.fetch(`/api/workspaces/${workspaceId}/vcs/connections`); diff --git a/packages/core/api/issue-wakeup.test.ts b/packages/core/api/issue-wakeup.test.ts new file mode 100644 index 00000000000..6de092cfdbf --- /dev/null +++ b/packages/core/api/issue-wakeup.test.ts @@ -0,0 +1,156 @@ +// @vitest-environment node +import { afterEach, expect, it, vi } from "vitest"; +import { ApiClient } from "./client"; +import { AgentTaskSchema } from "./schemas"; +afterEach(() => vi.unstubAllGlobals()); +const client = new ApiClient("https://api.example.test"); +it("does not present malformed wakeup state as an empty list", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify([{ id: "wake", enabled: "false" }])), + ), + ); + await expect(client.listIssueWakeups("issue")).rejects.toThrow( + "Could not load wakeups", + ); +}); +it("preserves an empty wakeup list", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("[]"))); + await expect(client.listIssueWakeups("issue")).resolves.toEqual([]); +}); +it("does not swallow a disable permission refusal", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response('{"error":"forbidden"}', { status: 403 }), + ), + ); + await expect(client.disableIssueWakeup("issue", "wake")).rejects.toThrow(); +}); + +it("rejects malformed wakeup summary counts", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue(new Response(JSON.stringify([{ active_count: "2" }]))), + ); + await expect(client.listIssueWakeupSummaries()).rejects.toThrow(); +}); +it("preserves empty summaries", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("[]"))); + await expect(client.listIssueWakeupSummaries()).resolves.toEqual([]); +}); + +const inventoryFilters = { + scope: "active", + kind: "all", + search: "", + agent_id: "", + offset: 0, + limit: 50, +} as const; +it("rejects malformed workspace inventory rather than hiding ongoing work", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ items: [], total: "0" })), + ), + ); + await expect(client.listWorkspaceWakeups(inventoryFilters)).rejects.toThrow( + "Could not load workspace wakeups", + ); +}); +it("preserves an empty page and its inventory counts", async () => { + const page = { + items: [], + total: 101, + counts: { all: 101, active: 100, disabled: 1, ended: 0 }, + agents: [], + }; + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(page))); + vi.stubGlobal("fetch", fetcher); + await expect( + client.listWorkspaceWakeups({ + ...inventoryFilters, + offset: 150, + search: "CI & release", + }), + ).resolves.toEqual(page); + expect(fetcher.mock.calls[0]![0]).toContain("search=CI+%26+release"); + expect(fetcher.mock.calls[0]![0]).toContain("offset=150"); +}); + +it("preserves wakeup origin while accepting old task responses", () => { + expect( + AgentTaskSchema.parse({ id: "run", wakeup_id: "wake", status: "deferred" }), + ).toMatchObject({ wakeup_id: "wake", status: "deferred" }); + expect(AgentTaskSchema.parse({ id: "run" }).wakeup_id).toBeUndefined(); +}); + +it("sends a scoped enable request without rewriting the configuration", async () => { + const fetcher = vi.fn().mockResolvedValue(new Response("{}")); + vi.stubGlobal("fetch", fetcher); + await client.enableIssueWakeup("issue", "wake", { + revision: 2, + rearm: true, + at: "2099-01-01T00:00:00Z", + }); + const [url, init] = fetcher.mock.calls[0]!; + expect(url).toContain("/api/issues/issue/wakeups/wake/enable"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ + revision: 2, + rearm: true, + at: "2099-01-01T00:00:00Z", + }); +}); +it("surfaces a stale enable refusal instead of reporting success", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response('{"error":"wakeup changed"}', { status: 409 }), + ), + ); + await expect( + client.enableIssueWakeup("issue", "wake", { revision: 1 }), + ).rejects.toThrow(); +}); + +it("preserves actor filters and accepts older responses without them", async () => { + const rule = { + id: "wake", issue_id: "issue", agent_id: "agent", agent_name: "Emacs", + instruction: "wait", kind: "event", mode: "once", event_types: ["comment.created"], + filter_agent_id: null, filter_task_id: null, interval_seconds: null, + cron_expression: null, timezone: "UTC", next_fire_at: null, enabled: true, + disabled_at: null, last_task_id: null, last_error: null, + }; + for (const fields of [{}, { filter_actor_type: "member", filter_actor_id: "user", filter_actor_name: "Jiayuan" }, { filter_actor_type: "agent", filter_actor_id: null, filter_actor_name: null }]) { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify([{ ...rule, ...fields }])))); + await expect(client.listIssueWakeups("issue")).resolves.toEqual([{ ...rule, ...fields }]); + } + for (const fields of [{ filter_actor_type: 42 }, { filter_actor_type: "robot" }, { filter_actor_id: 42 }, { filter_actor_name: 42 }]) { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify([{ ...rule, ...fields }])))); + await expect(client.listIssueWakeups("issue")).rejects.toThrow("Could not load wakeups"); + } +}); + +it("edits instructions through the scoped endpoint and propagates conflicts", async () => { + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetch); + const input = { instruction: "new", expected_instruction: "old", revision: 2 }; + await client.editIssueWakeupInstruction("issue", "wake", input); + expect(fetch.mock.calls[0]?.[0]).toContain("/api/issues/issue/wakeups/wake/instruction"); + expect(fetch.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ method: "PATCH", body: JSON.stringify(input) })); + fetch.mockResolvedValue(new Response('{"error":"conflict"}', { status: 409 })); + await expect(client.editIssueWakeupInstruction("issue", "wake", input)).rejects.toThrow(); +}); diff --git a/packages/core/api/runtime-profile-schema.test.ts b/packages/core/api/runtime-profile-schema.test.ts new file mode 100644 index 00000000000..a1a48bc7001 --- /dev/null +++ b/packages/core/api/runtime-profile-schema.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { RuntimeProfileSchema } from "./schemas"; +import { parseWithFallback } from "./schema"; +const profile = { + id: "p", + workspace_id: "ws", + display_name: "Custom", + protocol_family: "pi", + command_name: "wrapper", +}; +describe("runtime profile response compatibility", () => { + it("defaults old or malformed identities to the protocol", () => { + for (const runtime_type of [undefined, null, 42, ""]) { + expect( + RuntimeProfileSchema.parse({ ...profile, runtime_type }).runtime_type, + ).toBe("pi"); + } + }); + it("preserves runtime identities including future targets", () => { + for (const runtime_type of ["omp", "future-runtime"]) { + expect( + RuntimeProfileSchema.parse({ ...profile, runtime_type }).runtime_type, + ).toBe(runtime_type); + } + }); + it("uses a safe fallback for malformed responses", () => { + expect( + parseWithFallback( + { ...profile, command_name: 42 }, + RuntimeProfileSchema, + null, + { endpoint: "runtimeProfile" }, + ), + ).toBeNull(); + }); +}); diff --git a/packages/core/api/schema.test.ts b/packages/core/api/schema.test.ts index 68abbf9e73b..0769fe17b84 100644 --- a/packages/core/api/schema.test.ts +++ b/packages/core/api/schema.test.ts @@ -337,9 +337,136 @@ describe("ApiClient schema fallback", () => { const client = new ApiClient("https://api.example.test"); await expect(client.createIssue({ title: "Created" })).rejects.toThrow(); }); + + it("fails closed before POST when create properties are unsupported", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ApiClient("https://api.example.test"); + + await expect( + client.createIssue({ title: "Created", properties: { "property-1": "value" } }), + ).rejects.toThrow("does not support atomic custom properties"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.example.test/api/config"); + expect(fetchMock.mock.calls.some(([, init]) => init?.method === "POST")).toBe(false); + }); + + it("preflights properties and requires the canonical response snapshot", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ issue_create_properties_supported: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ...validIssue, + properties: { "property-1": ["first", "second"] }, + }), + { status: 201, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ApiClient("https://api.example.test"); + + await expect( + client.createIssue({ + title: "Created", + properties: { "property-1": ["second", "first", "second"] }, + }), + ).resolves.toMatchObject({ properties: { "property-1": ["first", "second"] } }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ method: "POST" }); + }); + + it("reports a created identifier when the response property snapshot mismatches", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ issue_create_properties_supported: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(validIssue), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ApiClient("https://api.example.test"); + + await expect( + client.createIssue({ title: "Created", properties: { "property-1": "value" } }), + ).rejects.toThrow("Issue MUL-1 was created"); + }); }); describe("comment source-context sub-issues", () => { + it("preflights manual sub-issue properties and validates their snapshot", async () => { + const issue = { + id: "issue-2", + workspace_id: "ws-1", + number: 2, + identifier: "MUL-2", + title: "Child", + description: null, + status: "todo", + priority: "none", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: "issue-1", + project_id: null, + position: 0, + stage: null, + start_date: null, + due_date: null, + metadata: {}, + properties: { "property-1": true }, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ issue_create_properties_supported: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(issue), { + status: 201, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const client = new ApiClient("https://api.example.test"); + + await expect( + client.createCommentSubIssue("comment-1", { + mode: "manual", + capture_token: "token", + issue: { title: "Child", properties: { "property-1": true } }, + }), + ).resolves.toMatchObject({ id: "issue-2" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + "https://api.example.test/api/comments/comment-1/sub-issues", + ); + }); + it("uses the dedicated endpoint for agent creation", async () => { stubFetchJson({ task_id: "task-1" }, 202); const client = new ApiClient("https://api.example.test"); @@ -784,6 +911,22 @@ describe("ApiClient schema fallback", () => { }); }); + describe("listIssueDuplicates", () => { + it("falls back to an empty relation when the body is null", async () => { + stubFetchJson(null); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssueDuplicates("issue-1"); + expect(res).toEqual({ duplicate_of: null, duplicates: [] }); + }); + + it("defaults missing fields instead of failing", async () => { + stubFetchJson({}); + const client = new ApiClient("https://api.example.test"); + const res = await client.listIssueDuplicates("issue-1"); + expect(res).toEqual({ duplicate_of: null, duplicates: [] }); + }); + }); + describe("getChildIssueProgress", () => { it("validates the response before query selectors iterate it", async () => { stubFetchJson({ progress: "invalid" }); diff --git a/packages/core/api/schemas.test.ts b/packages/core/api/schemas.test.ts index 9389031374c..5de48b3d655 100644 --- a/packages/core/api/schemas.test.ts +++ b/packages/core/api/schemas.test.ts @@ -541,6 +541,25 @@ describe("IssueTriggerPreviewSchema", () => { }); describe("TimelineEntriesSchema", () => { + it("preserves run-bound supplement delivery receipts", () => { + const parsed = TimelineEntriesSchema.parse([{ + type: "comment", + id: "supplement-1", + actor_type: "member", + actor_id: "user-1", + created_at: "2026-01-01T00:00:00Z", + content: "also cover rollback", + supplement_task_id: "task-1", + supplement_status: "delivered", + supplement_delivered_at: "2026-01-01T00:00:01Z", + }]); + expect(parsed[0]).toMatchObject({ + supplement_task_id: "task-1", + supplement_status: "delivered", + supplement_delivered_at: "2026-01-01T00:00:01Z", + }); + }); + it("preserves source_task_id for agent failure comments", () => { const parsed = TimelineEntriesSchema.parse([ { @@ -613,6 +632,20 @@ describe("TimelineEntriesSchema", () => { }); describe("AgentTaskListSchema", () => { + it("preserves negotiated supplement capability, ordered coverage and permission", () => { + const parsed = AgentTaskListSchema.parse([{ + id: "run", + supplement_capability: "task-supplement-v1", + supplement_comment_ids: ["comment-1", "comment-2"], + can_supplement: true, + }]); + expect(parsed[0]).toMatchObject({ + supplement_capability: "task-supplement-v1", + supplement_comment_ids: ["comment-1", "comment-2"], + can_supplement: true, + }); + }); + it.each([true, false, undefined, null, "true", 1])("safely parses comment cancellation metadata: %s", (value) => { const parsed = AgentTaskListSchema.parse([{ id: "run", cancelled_by_comment_change: value }]); expect(parsed).toHaveLength(1); @@ -1280,6 +1313,26 @@ describe("AppConfigSchema agent_conversation_starters_supported drift", () => { }); }); +describe("AppConfigSchema issue_create_properties_supported drift", () => { + it("defaults to false when the server predates atomic create properties", () => { + expect(AppConfigSchema.parse({}).issue_create_properties_supported).toBe(false); + }); + + it("coerces a malformed declaration to false", () => { + expect( + AppConfigSchema.parse({ issue_create_properties_supported: "yes" }) + .issue_create_properties_supported, + ).toBe(false); + }); + + it("carries a genuine declaration through", () => { + expect( + AppConfigSchema.parse({ issue_create_properties_supported: true }) + .issue_create_properties_supported, + ).toBe(true); + }); +}); + describe("AppConfigSchema cdn_signed drift", () => { it("defaults cdn_signed to false when the server omits it (pre-MUL-3254 servers)", () => { const parsed = AppConfigSchema.parse({ cdn_domain: "cdn.example.com" }); @@ -2208,6 +2261,23 @@ describe("issue status catalog schemas", () => { }); describe("TaskMessageListSchema", () => { + it("preserves call IDs and tolerates old or malformed optional identity", () => { + const base = { task_id: "task-1", seq: 1, type: "tool_result", output: "ok" }; + const parsed = parseWithFallback<{ call_id?: string; output?: string }[]>( + [ + { ...base, call_id: "execution:A" }, + base, + { ...base, call_id: null }, + { ...base, call_id: 42 }, + { ...base, call_id: {} }, + ], + TaskMessageListSchema, [], { endpoint: "GET /api/tasks/:id/messages" }, + ); + expect(parsed).toHaveLength(5); + expect(parsed.map((m) => m.call_id)).toEqual(["execution:A", undefined, undefined, undefined, undefined]); + expect(parsed.every((m) => m.output === "ok")).toBe(true); + }); + const row = { task_id: "task-1", issue_id: "issue-1", seq: 1, type: "tool_result", output: "log line" }; // The whole point of the field: a server that never sends it is saying diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 45ed09157ab..1be3216a9cd 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -46,7 +46,7 @@ import type { RedeemTelegramBindingTokenResponse, GroupedIssuesResponse, GitHubConnectResponse, - GitHubPullRequest, + IssuePullRequestsResponse, InboxItem, InboxWorkspaceUnread, Label, @@ -409,6 +409,7 @@ export const GitHubPullRequestSchema = z.object({ closed_at: z.string().nullable(), pr_created_at: z.string(), pr_updated_at: z.string(), + link_source: z.enum(["manual", "title", "branch", "auto"]).optional().catch(undefined), mergeable: z.string().nullable().optional(), merge_state_status: z.string().nullable().optional(), snapshot_available: z.boolean().optional(), @@ -428,12 +429,23 @@ export const GitHubPullRequestSchema = z.object({ changed_files: z.number().optional().default(0), }).loose(); +// A malformed auto_complete block degrades to null (the issue page shows no +// automation line) instead of discarding the PR list with it. +export const PRAutoCompleteSchema = z.object({ + state: z.string(), + pull_request_ids: z.array(z.string()).default([]), + issue_disabled: z.boolean().default(false), + workspace_enabled: z.boolean().default(true), +}).loose(); + export const IssuePullRequestsResponseSchema = z.object({ pull_requests: z.array(GitHubPullRequestSchema).default([]), + auto_complete: PRAutoCompleteSchema.nullable().optional().default(null).catch(null), }).loose(); -export const EMPTY_ISSUE_PULL_REQUESTS_RESPONSE: { pull_requests: GitHubPullRequest[] } = { +export const EMPTY_ISSUE_PULL_REQUESTS_RESPONSE: IssuePullRequestsResponse = { pull_requests: [], + auto_complete: null, }; // Label responses are consumed by settings tables and resource pickers. Keep @@ -756,6 +768,9 @@ export interface AppConfigResponse { /** Whether agent create/update persists `conversation_starters`. Older servers * silently ignored the unknown field, so absent must be treated as false. */ agent_conversation_starters_supported?: boolean; + /** Whether issue create atomically validates and persists `properties`. + * Older servers silently ignore the field, so absent means unsupported. */ + issue_create_properties_supported?: boolean; /** Whether deleting a comment keeps its replies and the server routes * DELETE /api/comments/{id}/keep-replies. Older servers deleted the replies * too, so absent must be treated as false (#8296). */ @@ -915,6 +930,10 @@ const TimelineEntrySchema = z.object({ reactions: z.array(ReactionSchema).optional(), attachments: z.array(AttachmentSchema).optional(), source_task_id: z.string().nullable().optional(), + supplement_task_id: z.string().optional().catch(undefined), + supplement_status: z.enum(["pending", "delivering", "delivered", "failed"]).optional().catch(undefined), + supplement_failure_reason: z.string().optional().catch(undefined), + supplement_delivered_at: z.string().optional().catch(undefined), // Tombstone marker (#8296). Lenient: a malformed value reads as a live // comment instead of failing the whole timeline. deleted_at: z.string().nullable().optional().catch(undefined), @@ -998,6 +1017,7 @@ export const AppConfigSchema = z.object({ feature_flags: FeatureFlagsSchema, local_worktree_supported: BooleanWithDefaultSchema(false), agent_conversation_starters_supported: BooleanWithDefaultSchema(false), + issue_create_properties_supported: BooleanWithDefaultSchema(false), comment_delete_keep_replies_supported: BooleanWithDefaultSchema(false), server_version: OptionalStringSchema, }).loose(); @@ -1016,6 +1036,8 @@ export const EMPTY_APP_CONFIG: AppConfigResponse = { local_worktree_supported: false, // Fail closed: old servers returned success while dropping the field. agent_conversation_starters_supported: false, + // Fail closed: old servers returned success while dropping create properties. + issue_create_properties_supported: false, // Fail closed: old servers delete a comment's replies with it. comment_delete_keep_replies_supported: false, feature_flags: {}, @@ -1057,6 +1079,10 @@ export const CommentSchema = z.object({ updated_at: z.string(), revision: z.number().int().positive().optional(), source_task_id: z.string().nullable().optional(), + supplement_task_id: z.string().optional().catch(undefined), + supplement_status: z.enum(["pending", "delivering", "delivered", "failed"]).optional().catch(undefined), + supplement_failure_reason: z.string().optional().catch(undefined), + supplement_delivered_at: z.string().optional().catch(undefined), // Set only on comments a quick action produced (MUL-5465). Server-only. quick_action_id: z.string().nullable().optional(), deleted_at: z.string().nullable().optional().catch(undefined), @@ -1276,6 +1302,13 @@ export const IssueSchema = z.object({ creator_type: z.string(), creator_id: z.string(), parent_issue_id: z.string().nullable(), + // Additive pointer (MUL-7349); same disproportionate-failure reasoning as + // status_name above, and older backends do not send it at all. + duplicate_of: z + .object({ id: z.string(), identifier: z.string(), title: z.string(), status: z.string() }) + .nullable() + .optional() + .catch(undefined), project_id: z.string().nullable(), position: z.number(), // Older backends predate `stage`; default to null so a missing field parses @@ -1522,6 +1555,11 @@ export const ChildIssuesResponseSchema = z.object({ issues: z.array(IssueSchema).default([]), }).loose(); +export const IssueDuplicatesResponseSchema = z.object({ + duplicate_of: IssueSchema.nullable().default(null), + duplicates: z.array(IssueSchema).default([]), +}).loose(); + export const ChildIssueProgressResponseSchema = z.object({ progress: z .array( @@ -1798,6 +1836,7 @@ const TaskUsageSchema = z.object({ }).loose(); export const AgentTaskSchema = z.object({ + wakeup_id: z.string().optional().catch(undefined), cancelled_by_comment_change: z.boolean().optional().catch(undefined), cancelled_by: TaskCancellationActorSchema.optional().catch(undefined), id: z.string(), @@ -1823,6 +1862,9 @@ export const AgentTaskSchema = z.object({ // entire execution log, so degrade that field to "absent" independently. coalesced_comment_ids: OptionalStringArraySchema, delivered_comment_ids: OptionalStringArraySchema, + supplement_capability: z.string().optional().catch(undefined), + supplement_comment_ids: OptionalStringArraySchema, + can_supplement: z.boolean().optional().catch(undefined), trigger_summary: z.string().optional(), kind: z.string().optional(), work_dir: z.string().optional().catch(undefined), @@ -1864,6 +1906,7 @@ export const AgentTaskListSchema = z.array(AgentTaskSchema); // field to "unknown" is the correct loss; deleting the run is not. Every other // field keeps a default for the same reason. export const TaskMessagePayloadSchema = z.object({ + call_id: z.string().optional().catch(undefined), task_id: z.string().default(""), issue_id: z.string().default(""), chat_session_id: z.string().optional(), @@ -3475,3 +3518,62 @@ export const EMPTY_JOIN_SHARE_LINK_RESPONSE: { workspace_id: "", workspace_slug: "", }; + +export const IssueWakeupSchema = z.object({ + id: z.string(), issue_id: z.string(), agent_id: z.string(), agent_name: z.string().default(""), + instruction: z.string(), kind: z.enum(["event", "at", "every", "cron"]), mode: z.enum(["once", "continuous"]), + event_types: z.array(z.string()), filter_agent_id: z.string().nullable(), filter_task_id: z.string().nullable(), + interval_seconds: z.number().nullable(), cron_expression: z.string().nullable(), timezone: z.string(), + next_fire_at: z.string().nullable(), enabled: z.boolean(), disabled_at: z.string().nullable(), + last_task_id: z.string().nullable(), last_error: z.string().nullable(), + filter_actor_type: z.enum(["member", "agent"]).nullable().optional(), + filter_actor_id: z.string().nullable().optional(), + filter_actor_name: z.string().nullable().optional(), + revision: z.number().int().positive().optional(), + filter_agent_name: z.string().nullable().optional(), last_task_status: z.string().nullable().optional(), +}); + +export const IssueWakeupSummaryRowSchema = IssueWakeupSchema.pick({ + id: true, issue_id: true, agent_id: true, agent_name: true, kind: true, mode: true, + event_types: true, filter_task_id: true, filter_agent_name: true, interval_seconds: true, + filter_actor_type: true, filter_actor_id: true, filter_actor_name: true, + cron_expression: true, timezone: true, next_fire_at: true, +}).extend({ active_count: z.number().int().positive(), event_count: z.number().int().nonnegative() }); + +export const WorkspaceWakeupPageSchema = z.object({ + items: z.array(IssueWakeupSchema.omit({ instruction: true }).extend({ + issue_title: z.string(), issue_identifier: z.string(), issue_closed: z.boolean(), + can_manage: z.boolean(), active_runs: z.number().int().nonnegative(), + task: AgentTaskSchema.nullable(), + })), + total: z.number().int().nonnegative(), + counts: z.object({ + active: z.number().int().nonnegative(), all: z.number().int().nonnegative(), + disabled: z.number().int().nonnegative(), ended: z.number().int().nonnegative(), + }), + agents: z.array(z.object({ id: z.string(), name: z.string() })), +}); + +// Older servers omit runtime_type; the protocol remains their compatibility target. +export const RuntimeProfileSchema = z + .object({ + id: z.string(), + workspace_id: z.string(), + display_name: z.string(), + protocol_family: z.string(), + runtime_type: z.string().nullish().catch(undefined), + command_name: z.string(), + description: z.string().nullable().catch(null), + fixed_args: z.array(z.string()).catch([]), + visibility: z.string().catch("workspace"), + created_by: z.string().nullable().catch(null), + enabled: z.boolean().catch(true), + created_at: z.string().catch(""), + updated_at: z.string().catch(""), + }) + .passthrough() + .transform((profile) => ({ + ...profile, + runtime_type: profile.runtime_type || profile.protocol_family, + })); +export const RuntimeProfileListSchema = z.array(RuntimeProfileSchema); diff --git a/packages/core/attachments/image-sequence.test.ts b/packages/core/attachments/image-sequence.test.ts index 29e7110232d..f13732f2f5c 100644 --- a/packages/core/attachments/image-sequence.test.ts +++ b/packages/core/attachments/image-sequence.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Attachment } from "../types/attachment"; import { + collectAttachmentSequence, collectImageSequence, indexOfImageKey, isImageAttachment, @@ -143,6 +144,7 @@ describe("collectImageSequence", () => { url: "https://cdn/x.png", filename: "shot", attachment: undefined, + imageByConstruction: true, }, ]); }); @@ -204,6 +206,101 @@ describe("collectImageSequence", () => { }); }); +describe("collectAttachmentSequence", () => { + // The web / desktop rule: anything the viewer can open. + const previewable = ({ contentType, filename }: { contentType: string; filename: string }) => + isImageAttachment(contentType, filename) || + contentType === "application/pdf" || + filename.endsWith(".md"); + + it("pages through every kind the rule accepts, in render order", () => { + const pdf = attachment({ + id: UUID_A, + filename: "spec.pdf", + content_type: "application/pdf", + }); + const image = attachment({ id: UUID_B }); + const zip = attachment({ + id: UUID_C, + filename: "bundle.zip", + content_type: "application/zip", + }); + + const sequence = collectAttachmentSequence( + [ + { + content: `![shot](/api/attachments/${UUID_B}/download)`, + attachments: [image, pdf, zip], + }, + ], + previewable, + ); + + expect(sequence.map((i) => i.key)).toEqual([UUID_B, UUID_A]); + expect(sequence.map((i) => i.imageByConstruction)).toEqual([true, false]); + }); + + it("asks the rule about file cards and says whether they resolved", () => { + const seen: Array<{ filename: string; hasRecord: boolean }> = []; + const notes = attachment({ + id: UUID_A, + filename: "notes.md", + content_type: "text/markdown", + }); + + const sequence = collectAttachmentSequence( + [ + { + content: [ + `!file[notes.md](/api/attachments/${UUID_A}/download)`, + "!file[orphan.md](https://cdn/orphan.md)", + ].join("\n\n"), + attachments: [notes], + }, + ], + (candidate) => { + seen.push({ filename: candidate.filename, hasRecord: candidate.hasRecord }); + return candidate.hasRecord; + }, + ); + + expect(seen).toEqual([ + { filename: "notes.md", hasRecord: true }, + { filename: "orphan.md", hasRecord: false }, + ]); + expect(sequence.map((i) => i.key)).toEqual([UUID_A]); + }); + + it("resolves a non-standalone block's references without listing the rest", () => { + const inline = attachment({ id: UUID_A }); + const elsewhere = attachment({ id: UUID_B, filename: "later.png" }); + const sequence = collectAttachmentSequence( + [ + { + content: `![](/api/attachments/${UUID_A}/download)`, + attachments: [inline, elsewhere], + standalone: false, + }, + { attachments: [elsewhere] }, + ], + previewable, + ); + // `elsewhere` sits at its own block's position, not the first block's. + expect(sequence.map((i) => [i.key, i.attachment])).toEqual([ + [UUID_A, inline], + [UUID_B, elsewhere], + ]); + }); + + it("keeps markdown images even when the rule would reject their caption", () => { + const sequence = collectAttachmentSequence( + [{ content: "![报告图表](https://cdn/chart)" }], + () => false, + ); + expect(sequence.map((i) => i.key)).toEqual(["https://cdn/chart"]); + }); +}); + describe("indexOfImageKey", () => { it("finds a key and reports -1 for anything else", () => { const items = collectImageSequence([ diff --git a/packages/core/attachments/image-sequence.ts b/packages/core/attachments/image-sequence.ts index 0aa0e574054..0bacb7c2edf 100644 --- a/packages/core/attachments/image-sequence.ts +++ b/packages/core/attachments/image-sequence.ts @@ -1,12 +1,11 @@ /** - * Image sequence — the ordered list of images one surface exposes to the - * preview viewer's prev / next navigation (MUL-5752). + * Attachment sequence — the ordered list of attachments one surface exposes + * to the preview viewer's prev / next navigation (MUL-5752, MUL-7642). * - * Scope is deliberately narrow: ONLY images. PDFs, video, audio, markdown, - * HTML and text attachments never enter this sequence — mixing kinds would - * mean defining per-kind load/keyboard/state semantics for a "next" that can - * land on a PDF page or a playing video. A non-image attachment simply keeps - * opening its own single-file preview. + * `collectAttachmentSequence` takes the inclusion rule from the caller: web / + * desktop page through every previewable kind (images, PDFs, video, Markdown, + * HTML, text), so an issue reads as one run of files. `collectImageSequence` + * is the images-only rule mobile's lightbox is built on. * * The sequence is built from DATA, not from the DOM: both the issue timeline * and the chat message list are virtualized, so a registry of mounted @@ -196,18 +195,18 @@ function maskCode(content: string): string { return lines.join("\n"); } -interface InlineImageRef { +interface InlineRef { index: number; url: string; /** Best filename hint available at the reference site. */ filename: string; /** - * Whether the reference still has to pass the image test. Markdown `![]()` + * `!file[name](url)` card rather than an image reference. Markdown `![]()` * and `` are images by construction (the renderers pass - * `forceKind: "image"`); a `!file[name](url)` card only renders as an image - * when its name/type says so. + * `forceKind: "image"`); a card is whatever its name/type says it is, so it + * still has to pass the caller's inclusion rule. */ - requiresImageCheck: boolean; + isFileCard: boolean; } // `![alt](url "title")`. The URL char class stops at whitespace and `)` so a @@ -222,18 +221,18 @@ const HTML_IMAGE_RE = /]*?\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s">']+))[^>]*>/gi; // `!file[name](url)` on its own line — `preprocessFileCards` turns it into a -// fileCard div, which the renderer hands to ; an image filename -// there renders as an inline image, not a card. +// fileCard div, which the renderer hands to : an image filename +// renders as an inline image, anything else as a file card. const FILE_CARD_LINE_RE = /^[ \t]*!file\[((?:\\.|[^\]\\\n])*)\]\(([^)\s]+)\)[ \t]*$/gm; function unescapeLabel(label: string): string { return label.replace(/\\([[\]\\()])/g, "$1"); } -function extractInlineImageRefs(rawContent: string): InlineImageRef[] { +function extractInlineRefs(rawContent: string): InlineRef[] { if (!rawContent) return []; const content = maskCode(rawContent); - const refs: InlineImageRef[] = []; + const refs: InlineRef[] = []; for (const m of content.matchAll(MARKDOWN_IMAGE_RE)) { // Two alternations: `<...>`-wrapped URL (groups 1/2) or bare (groups 3/4). @@ -244,7 +243,7 @@ function extractInlineImageRefs(rawContent: string): InlineImageRef[] { index: m.index ?? 0, url, filename: unescapeLabel(alt), - requiresImageCheck: false, + isFileCard: false, }); } @@ -255,7 +254,7 @@ function extractInlineImageRefs(rawContent: string): InlineImageRef[] { index: m.index ?? 0, url, filename: "", - requiresImageCheck: false, + isFileCard: false, }); } @@ -266,7 +265,7 @@ function extractInlineImageRefs(rawContent: string): InlineImageRef[] { index: m.index ?? 0, url, filename: unescapeLabel(m[1] ?? ""), - requiresImageCheck: true, + isFileCard: true, }); } @@ -277,12 +276,12 @@ function extractInlineImageRefs(rawContent: string): InlineImageRef[] { // Sequence // --------------------------------------------------------------------------- -/** One image the viewer can page to. */ +/** One attachment the viewer can page to. */ export interface ImageSequenceItem { /** - * Identity used to open the sequence at the clicked image. The attachment id - * when the reference resolves to a record, otherwise the URL as written in - * the body — matching what the renderer knows at click time. + * Identity used to open the sequence at the clicked attachment. The + * attachment id when the reference resolves to a record, otherwise the URL + * as written in the body — matching what the renderer knows at click time. */ key: string; /** Best-known URL. Callers holding `attachment` should prefer re-resolving. */ @@ -290,24 +289,50 @@ export interface ImageSequenceItem { filename: string; /** Present when the reference resolved to a workspace attachment record. */ attachment?: Attachment; + /** + * The body references this as an image (`![]()` / ``), so it renders + * as one whatever `filename` says — for such a reference `filename` is the + * markdown caption, which is prose with no extension to read (MUL-7518). + */ + imageByConstruction: boolean; } /** One renderable unit: an issue description, a comment, a chat message. */ export interface ImageSequenceBlock { content?: string | null; attachments?: ReadonlyArray | null; + /** + * Whether the surface renders this block's unreferenced attachments as cards + * under it. Defaults to true. An issue description renders none, and its + * `attachments` is the whole issue's list (comment uploads keep `issue_id`), + * so there it only resolves the body's references. + */ + standalone?: boolean; +} + +/** What an inclusion rule gets to decide on. */ +export interface SequenceCandidate { + contentType: string; + filename: string; + /** False for a body reference that did not resolve to an attachment record. */ + hasRecord: boolean; } /** - * Flatten blocks into the ordered image sequence a viewer walks. + * Flatten blocks into the ordered sequence a viewer walks. + * + * Order is render order: for each block, references inline in the body in + * text order, then the standalone attachment cards rendered under it. Repeats + * of the same attachment collapse to their first position, so the counter + * matches the number of distinct files rather than the number of references. * - * Order is render order: for each block, images inline in the body in text - * order, then the standalone attachment cards rendered under it. Repeats of - * the same image collapse to their first position, so the counter matches the - * number of distinct images rather than the number of references. + * Image references (`![]()` / ``) are always kept — they render as + * images by construction. File cards and standalone attachments are kept + * when `include` accepts them. */ -export function collectImageSequence( +export function collectAttachmentSequence( blocks: ReadonlyArray, + include: (candidate: SequenceCandidate) => boolean, ): ImageSequenceItem[] { const items: ImageSequenceItem[] = []; const seen = new Set(); @@ -323,13 +348,17 @@ export function collectImageSequence( const content = block.content ?? ""; const attachments = block.attachments ?? []; - for (const ref of extractInlineImageRefs(content)) { + for (const ref of extractInlineRefs(content)) { const attachment = matchAttachmentByURL(ref.url, attachments); - if (ref.requiresImageCheck) { - const isImage = attachment - ? isImageAttachment(attachment.content_type, attachment.filename) - : isImageAttachment("", ref.filename || ref.url); - if (!isImage) continue; + if ( + ref.isFileCard && + !include({ + contentType: attachment?.content_type ?? "", + filename: attachment?.filename || ref.filename || ref.url, + hasRecord: !!attachment, + }) + ) { + continue; } push({ key: attachment?.id ?? ref.url, @@ -340,11 +369,19 @@ export function collectImageSequence( ref.url, filename: attachment?.filename || ref.filename, attachment, + imageByConstruction: !ref.isFileCard, }); } + if (block.standalone === false) continue; for (const attachment of selectStandaloneAttachments(content, attachments)) { - if (!isImageAttachment(attachment.content_type, attachment.filename)) { + if ( + !include({ + contentType: attachment.content_type, + filename: attachment.filename, + hasRecord: true, + }) + ) { continue; } push({ @@ -353,6 +390,7 @@ export function collectImageSequence( attachment.download_url || attachment.markdown_url || attachment.url, filename: attachment.filename, attachment, + imageByConstruction: false, }); } } @@ -360,6 +398,15 @@ export function collectImageSequence( return items; } +/** The images-only sequence (mobile's lightbox). */ +export function collectImageSequence( + blocks: ReadonlyArray, +): ImageSequenceItem[] { + return collectAttachmentSequence(blocks, ({ contentType, filename }) => + isImageAttachment(contentType, filename), + ); +} + /** Index of `key` in the sequence, or -1 when it is not part of it. */ export function indexOfImageKey( items: ReadonlyArray, diff --git a/packages/core/attachments/index.ts b/packages/core/attachments/index.ts index ef2f05ee999..4bfb6600a87 100644 --- a/packages/core/attachments/index.ts +++ b/packages/core/attachments/index.ts @@ -1,4 +1,5 @@ export { + collectAttachmentSequence, collectImageSequence, indexOfImageKey, isImageAttachment, @@ -6,4 +7,5 @@ export { selectStandaloneAttachments, type ImageSequenceBlock, type ImageSequenceItem, + type SequenceCandidate, } from "./image-sequence"; diff --git a/packages/core/config/index.ts b/packages/core/config/index.ts index 43ea1461723..40d1208213b 100644 --- a/packages/core/config/index.ts +++ b/packages/core/config/index.ts @@ -36,6 +36,9 @@ interface ConfigState { // Older handlers accepted the unknown field and returned success while // dropping it, so absent must fail closed. agentConversationStartersSupported: boolean; + // Whether POST /api/issues atomically persists custom-property values. + // Older servers silently drop the field, so absent must fail closed. + issueCreatePropertiesSupported: boolean; // Whether deleting a comment keeps its replies (#8296). Older servers // deleted the replies too, so absent must fail closed: the client then // promises nothing about replies and uses the legacy delete route. @@ -55,6 +58,7 @@ interface ConfigState { setServerVersion: (version?: string) => void; setLocalWorktreeSupported: (supported?: boolean) => void; setAgentConversationStartersSupported: (supported?: boolean) => void; + setIssueCreatePropertiesSupported: (supported?: boolean) => void; setCommentDeleteKeepRepliesSupported: (supported?: boolean) => void; } @@ -71,6 +75,7 @@ export const configStore = createStore((set) => ({ serverVersion: "", localWorktreeSupported: false, agentConversationStartersSupported: false, + issueCreatePropertiesSupported: false, commentDeleteKeepRepliesSupported: false, setCdnConfig: ({ cdnDomain, cdnSigned = false }) => set({ cdnDomain, cdnSigned }), setAuthConfig: ({ @@ -87,6 +92,8 @@ export const configStore = createStore((set) => ({ set({ localWorktreeSupported: supported === true }), setAgentConversationStartersSupported: (supported = false) => set({ agentConversationStartersSupported: supported === true }), + setIssueCreatePropertiesSupported: (supported = false) => + set({ issueCreatePropertiesSupported: supported === true }), setCommentDeleteKeepRepliesSupported: (supported = false) => set({ commentDeleteKeepRepliesSupported: supported === true }), })); diff --git a/packages/core/drafts/register-all-drafts.ts b/packages/core/drafts/register-all-drafts.ts index 41a0f4be33b..59968001532 100644 --- a/packages/core/drafts/register-all-drafts.ts +++ b/packages/core/drafts/register-all-drafts.ts @@ -17,6 +17,7 @@ import "../issues/stores/draft-store"; import "../issues/stores/quick-create-store"; import "../issues/stores/comment-draft-store"; +import "../issues/stores/task-supplement-draft-store"; import "../projects/draft-store"; import "../feedback/draft-store"; import "../agents/manual-draft-store"; diff --git a/packages/core/github/index.ts b/packages/core/github/index.ts index 5602216d05c..dde1485c9ce 100644 --- a/packages/core/github/index.ts +++ b/packages/core/github/index.ts @@ -1,4 +1,6 @@ export * from "./queries"; +export * from "./mutations"; export * from "./pull-request-status"; export * from "./settings"; export * from "./use-github-settings"; +export * from "./repo-ref"; diff --git a/packages/core/github/mutations.ts b/packages/core/github/mutations.ts new file mode 100644 index 00000000000..59200b7e328 --- /dev/null +++ b/packages/core/github/mutations.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import type { IssuePullRequestsResponse } from "../types"; +import { githubKeys } from "./queries"; + +// Each endpoint answers with the issue's fresh PR list and auto-complete +// decision, so the cache is replaced with the server's answer rather than +// patched optimistically. A status change the action caused (auto-complete) +// arrives through the issue:updated realtime event. +function useWritePullRequests(issueId: string) { + const qc = useQueryClient(); + return (data: IssuePullRequestsResponse) => { + qc.setQueryData(githubKeys.pullRequests(issueId), data); + }; +} + +export function useLinkIssuePullRequest(issueId: string) { + const write = useWritePullRequests(issueId); + return useMutation({ + mutationFn: (body: { url: string } | { pull_request_id: string }) => + api.linkIssuePullRequest(issueId, body), + onSuccess: write, + }); +} + +export function useUnlinkIssuePullRequest(issueId: string) { + const write = useWritePullRequests(issueId); + return useMutation({ + mutationFn: (pullRequestId: string) => api.unlinkIssuePullRequest(issueId, pullRequestId), + onSuccess: write, + }); +} + +export function useSetIssuePRAutoComplete(issueId: string) { + const write = useWritePullRequests(issueId); + return useMutation({ + mutationFn: (disabled: boolean) => api.setIssuePRAutoComplete(issueId, disabled), + onSuccess: write, + }); +} diff --git a/packages/core/github/repo-ref.test.ts b/packages/core/github/repo-ref.test.ts new file mode 100644 index 00000000000..a8b9d904e15 --- /dev/null +++ b/packages/core/github/repo-ref.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { + GIT_REF_MAX_LENGTH, + looksLikeCommitSha, + splitGithubUrlRef, + validateGitRef, +} from "./repo-ref"; + +describe("validateGitRef", () => { + it("accepts an empty ref as 'use the default branch'", () => { + expect(validateGitRef("")).toEqual({ ok: true }); + expect(validateGitRef(" ")).toEqual({ ok: true }); + }); + + it.each([ + ["a plain branch", "main"], + ["a slashed branch", "release/2026-09"], + ["a deeply slashed branch", "team/alice/feat/new-thing"], + ["a semver tag", "v1.2.3"], + ["a short SHA", "a1b2c3d"], + ["a full SHA", "5e0b1cfa0a7d6a1a0f4b3f2e1d0c9b8a7f6e5d4c"], + ["a branch with dots inside", "release.2026.09"], + ["a branch with a hyphenated tail", "fix/MUL-7504-pin-ref"], + ])("accepts %s", (_label, ref) => { + expect(validateGitRef(ref)).toEqual({ ok: true }); + }); + + it.each([ + ["a space", "my branch"], + ["a tilde", "main~1"], + ["a caret", "main^"], + ["a colon", "origin:main"], + ["a question mark", "main?"], + ["an asterisk", "refs/heads/*"], + ["a backslash", "feat\\thing"], + ["an interior control character", "ma\nin"], + ])("rejects %s", (_label, ref) => { + expect(validateGitRef(ref)).toEqual({ ok: false, reason: "invalid_characters" }); + }); + + it.each([ + ["a range", "main..dev"], + ["reflog syntax", "main@{1}"], + ["a lone at-sign", "@"], + ["a leading slash", "/main"], + ["a trailing slash", "main/"], + ["a doubled slash", "feat//thing"], + ["a leading dot", ".hidden"], + ["a trailing dot", "main."], + ["a lock suffix", "main.lock"], + ["a lock suffix on an inner segment", "feat.lock/thing"], + ["a dot-prefixed inner segment", "feat/.hidden"], + ])("rejects %s", (_label, ref) => { + expect(validateGitRef(ref)).toEqual({ ok: false, reason: "invalid_format" }); + }); + + it("rejects a ref past the length cap but accepts one exactly at it", () => { + expect(validateGitRef("a".repeat(GIT_REF_MAX_LENGTH))).toEqual({ ok: true }); + expect(validateGitRef("a".repeat(GIT_REF_MAX_LENGTH + 1))).toEqual({ + ok: false, + reason: "too_long", + }); + }); +}); + +describe("splitGithubUrlRef", () => { + it("splits a browse URL into clone URL and ref", () => { + expect(splitGithubUrlRef("https://github.com/multica-ai/multica/tree/main")).toEqual({ + url: "https://github.com/multica-ai/multica", + ref: "main", + }); + }); + + it("keeps a multi-segment branch together", () => { + expect( + splitGithubUrlRef("https://github.com/multica-ai/multica/tree/release/2026-09"), + ).toEqual({ + url: "https://github.com/multica-ai/multica", + ref: "release/2026-09", + }); + }); + + it("drops a .git suffix before /tree and a trailing slash after the ref", () => { + expect( + splitGithubUrlRef("https://github.com/multica-ai/multica.git/tree/main/"), + ).toEqual({ url: "https://github.com/multica-ai/multica", ref: "main" }); + }); + + it("leaves a plain clone URL untouched", () => { + for (const url of [ + "https://github.com/multica-ai/multica", + "https://github.com/multica-ai/multica.git", + "git@github.com:multica-ai/multica.git", + "https://gitlab.com/owner/repo/tree/main", + ]) { + expect(splitGithubUrlRef(url)).toEqual({ url }); + } + }); + + it("leaves /blob and /pull URLs alone — neither names a checkout baseline", () => { + const blob = "https://github.com/multica-ai/multica/blob/main/README.md"; + const pull = "https://github.com/multica-ai/multica/pull/8572"; + expect(splitGithubUrlRef(blob)).toEqual({ url: blob }); + expect(splitGithubUrlRef(pull)).toEqual({ url: pull }); + }); + + it("does not split when the extracted ref would be invalid", () => { + const url = "https://github.com/multica-ai/multica/tree/main..dev"; + expect(splitGithubUrlRef(url)).toEqual({ url }); + }); + + it("trims surrounding whitespace from a pasted URL", () => { + expect(splitGithubUrlRef(" https://github.com/o/r/tree/dev ")).toEqual({ + url: "https://github.com/o/r", + ref: "dev", + }); + }); +}); + +describe("looksLikeCommitSha", () => { + it("recognises full-length object ids", () => { + expect(looksLikeCommitSha("5e0b1cfa0a7d6a1a0f4b3f2e1d0c9b8a7f6e5d4c")).toBe(true); + expect(looksLikeCommitSha("5E0B1CFA0A7D6A1A0F4B3F2E1D0C9B8A7F6E5D4C")).toBe(true); + expect(looksLikeCommitSha("a".repeat(64))).toBe(true); + expect(looksLikeCommitSha(" " + "b".repeat(40) + " ")).toBe(true); + }); + + it("leaves anything that could plausibly be a branch alone", () => { + for (const value of [ + "main", + "release/2026-09", + "v1.2.3", + "a1b2c3d", // a short SHA is also a legal branch name — not our call to make + "deadbeef", + "a".repeat(39), + "a".repeat(41), + "g".repeat(40), // not hex + "", + ]) { + expect(looksLikeCommitSha(value)).toBe(false); + } + }); + + it("is independent of validateGitRef — a SHA is still a valid ref to store", () => { + const sha = "5e0b1cfa0a7d6a1a0f4b3f2e1d0c9b8a7f6e5d4c"; + expect(validateGitRef(sha)).toEqual({ ok: true }); + expect(looksLikeCommitSha(sha)).toBe(true); + }); +}); diff --git a/packages/core/github/repo-ref.ts b/packages/core/github/repo-ref.ts new file mode 100644 index 00000000000..f8726c03b2b --- /dev/null +++ b/packages/core/github/repo-ref.ts @@ -0,0 +1,126 @@ +/** + * Checkout-ref helpers for `github_repo` project resources. + * + * The ref pins where a project's tasks START — `multica repo checkout` falls + * back to the remote default branch when it is empty, and an explicit + * `--ref` on the command line still wins over it. It is not a promise that + * work lands on that branch, and it does not retarget pull requests. + * + * Lives in core (not views) because the mobile app needs the same rules and + * cannot import from the web-only package. + */ + +/** + * Git's own limit is the filesystem's, but a loose ref path has to fit in a + * file name. 255 is the conservative ceiling every platform honors, and it is + * far past any real branch name — the cap exists to stop a paste of an entire + * document from reaching the database, not to police naming. + */ +export const GIT_REF_MAX_LENGTH = 255; + +export type GitRefInvalidReason = + | "too_long" + | "invalid_characters" + | "invalid_format"; + +export type GitRefValidation = + | { ok: true } + | { ok: false; reason: GitRefInvalidReason }; + +/** + * Reject input git itself could never resolve, and nothing more. + * + * Mirrors the subset of `git check-ref-format` that applies to a ref a user + * types: it must accept every branch, tag and commit SHA we advertise, so the + * rules here are about SHAPE only. Whether the ref exists on the remote is a + * different question answered at checkout time — the daemon may be offline and + * the repository may be private, so existence must never gate saving config. + * + * An empty ref is valid: it means "use the repository's default branch". + */ +export function validateGitRef(ref: string): GitRefValidation { + const value = ref.trim(); + if (value === "") return { ok: true }; + if (value.length > GIT_REF_MAX_LENGTH) return { ok: false, reason: "too_long" }; + + // ASCII control characters, DEL, space, and the characters git reserves for + // its own revision syntax. Scanned by code point rather than matched with a + // regex so the control range stays readable (and lintable) as a comparison. + const RESERVED = "~^:?*[\\"; + for (const char of value) { + const code = char.codePointAt(0)!; + if (code <= 0x20 || code === 0x7f || RESERVED.includes(char)) { + return { ok: false, reason: "invalid_characters" }; + } + } + + // Path-shape rules. `@{` is reflog syntax; a lone `@` is shorthand for HEAD; + // `..` would read as a range; `.lock` is what git names its lock files. + if ( + value.includes("..") || + value.includes("@{") || + value === "@" || + value.startsWith("/") || + value.endsWith("/") || + value.includes("//") || + value.startsWith(".") || + value.endsWith(".") || + value.endsWith(".lock") || + value.split("/").some((segment) => segment.startsWith(".") || segment.endsWith(".lock")) + ) { + return { ok: false, reason: "invalid_format" }; + } + + return { ok: true }; +} + +/** + * True when the value can only be a commit SHA, never a branch name. + * + * Git cannot tell a branch from a tag from a commit by looking at the string — + * `v1.2.3` is a legal branch name and `main` is a legal tag — and answering + * properly means asking the remote, which the product deliberately does not do. + * A full-length hex object id is the one exception: it is unambiguous, and it + * is what someone pastes when they mean "this exact commit". + * + * That matters because a pinned starting point is also the branch a task + * delivers back to, and a commit has nothing to merge into. So the UI, which + * asks for a branch, declines this one shape and points at the per-task + * `--ref` escape hatch instead. The stored grammar (validateGitRef, mirrored + * server-side) stays permissive: the CLI and API still accept tags and commits, + * because the daemon resolves all three and always has. + */ +export function looksLikeCommitSha(value: string): boolean { + const trimmed = value.trim(); + // SHA-1 object ids are 40 hex chars; git's SHA-256 transition uses 64. + return /^[0-9a-f]{40}$|^[0-9a-f]{64}$/i.test(trimmed); +} + +/** + * Split a pasted GitHub "browse" URL into the clone URL plus the ref it points at. + * + * Someone who wants a branch reaches for the URL bar first, and + * `https://github.com/owner/repo/tree/release/2026-09` is what they copy. Left + * alone that whole string was stored as the repository URL — a clone target + * that does not exist. Splitting it is what the user meant. + * + * Only `/tree/` is recognised: `/blob/…` and `/pull/…` point at a file or + * a PR rather than a checkout baseline, so those are left untouched for the + * URL validator to reject or accept on its own terms. + * + * A multi-segment ref (`release/2026-09`) is rejoined — GitHub's own URLs are + * ambiguous between `feat/x` the branch and `feat` the branch plus `x` the + * directory, and the branch reading is the useful one here. A wrong guess is + * visible and editable in the field before it is saved. + */ +export function splitGithubUrlRef(input: string): { url: string; ref?: string } { + const value = input.trim(); + const match = value.match( + /^(https?:\/\/(?:www\.)?github\.com\/[^/\s]+\/[^/\s]+?)(?:\.git)?\/tree\/(.+)$/i, + ); + const [, cloneUrl, rawRef] = match ?? []; + if (!cloneUrl || !rawRef) return { url: value }; + const ref = rawRef.replace(/\/+$/, ""); + if (!ref || validateGitRef(ref).ok === false) return { url: value }; + return { url: cloneUrl, ref }; +} diff --git a/packages/core/github/settings.test.ts b/packages/core/github/settings.test.ts index d277dfaaf65..6c03665c3eb 100644 --- a/packages/core/github/settings.test.ts +++ b/packages/core/github/settings.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { deriveGitHubSettings } from "./settings"; +import { deriveGitHubSettings, derivePRAutoCompleteEnabled } from "./settings"; import type { Workspace } from "../types"; function ws(settings: Record): Pick { @@ -64,3 +64,15 @@ describe("deriveGitHubSettings", () => { ).toMatchObject({ enabled: true, prSidebar: true }); }); }); + +describe("derivePRAutoCompleteEnabled", () => { + it("is on unless explicitly turned off", () => { + expect(derivePRAutoCompleteEnabled(null)).toBe(true); + expect(derivePRAutoCompleteEnabled(ws({}))).toBe(true); + expect(derivePRAutoCompleteEnabled(ws({ pr_auto_complete_enabled: false }))).toBe(false); + }); + + it("does not follow the GitHub master switch", () => { + expect(derivePRAutoCompleteEnabled(ws({ github_enabled: false }))).toBe(true); + }); +}); diff --git a/packages/core/github/settings.ts b/packages/core/github/settings.ts index 5a6c66d5cb1..9440ec6273a 100644 --- a/packages/core/github/settings.ts +++ b/packages/core/github/settings.ts @@ -27,3 +27,15 @@ export function deriveGitHubSettings( autoLinkPRs: enabled && s.github_auto_link_prs_enabled !== false, }; } + +/** + * Workspace-wide PR auto-complete: when every PR linked to an issue is merged, + * the issue moves to Done. Absent means on. Not GitHub-specific — self-hosted + * providers follow the same setting — so it ignores the GitHub master switch. + */ +export function derivePRAutoCompleteEnabled( + workspace: Pick | null | undefined, +): boolean { + const s = (workspace?.settings ?? {}) as Record; + return s.pr_auto_complete_enabled !== false; +} diff --git a/packages/core/issue-views/active-view-store.ts b/packages/core/issue-views/active-view-store.ts index abe50df6cfd..7b77b590c41 100644 --- a/packages/core/issue-views/active-view-store.ts +++ b/packages/core/issue-views/active-view-store.ts @@ -51,6 +51,7 @@ export function lockedDimensionsFromQuery( if (nonEmptyArray(query.projectFilters) || query.includeNoProject === true) { locked.add("project"); } + if (nonEmptyArray(query.projectStatusFilters)) locked.add("projectStatus"); if (nonEmptyArray(query.labelFilters)) locked.add("label"); const propertyFilters = query.propertyFilters; if (propertyFilters && typeof propertyFilters === "object") { diff --git a/packages/core/issue-views/baseline.test.ts b/packages/core/issue-views/baseline.test.ts index c2a0cfeb14b..153b988ab9f 100644 --- a/packages/core/issue-views/baseline.test.ts +++ b/packages/core/issue-views/baseline.test.ts @@ -59,3 +59,29 @@ describe("baselineFromQuery property filters", () => { expect(baseline.property.size).toBe(0); }); }); + +// Saved views predate the project-status dimension, so every read path has to +// treat a missing key as "no filter" rather than as a value. +describe("baselineFromQuery project status filters", () => { + it("keeps known project statuses", () => { + const baseline = baselineFromQuery({ + projectStatusFilters: ["in_progress", "completed"], + }); + expect(baseline.raw.projectStatusFilters).toEqual(["in_progress", "completed"]); + expect(baseline.projectStatus.has("in_progress")).toBe(true); + }); + + it("treats a view saved before the dimension existed as no filter", () => { + const baseline = baselineFromQuery({ projectFilters: ["p-1"] }); + expect(baseline.raw.projectStatusFilters).toEqual([]); + expect(baseline.projectStatus.size).toBe(0); + }); + + it("drops values the store cannot represent", () => { + const baseline = baselineFromQuery({ + // "backlog" is an issue status; the project lifecycle has no such value. + projectStatusFilters: ["in_progress", "backlog", 7, null], + }); + expect(baseline.raw.projectStatusFilters).toEqual(["in_progress"]); + }); +}); diff --git a/packages/core/issue-views/baseline.ts b/packages/core/issue-views/baseline.ts index 5f6759c1617..af293fae36f 100644 --- a/packages/core/issue-views/baseline.ts +++ b/packages/core/issue-views/baseline.ts @@ -1,7 +1,8 @@ import type { ActorFilterValue, FilterSnapshot } from "../issues/stores/view-store"; -import type { IssuePriority, IssueStatus, PropertyFilterValue } from "../types"; +import type { IssuePriority, IssueStatus, ProjectStatus, PropertyFilterValue } from "../types"; import { isKnownPropertyFilterOp, isPropertyOperatorFilter, propertyFilterValueKey } from "../types"; import { PRIORITY_DISPLAY_ORDER } from "../issues/config"; +import { PROJECT_STATUS_ORDER } from "../projects/config"; /** * The open saved view's query, normalized for two jobs: @@ -21,6 +22,7 @@ export interface IssueViewBaseline { creator: Set; project: Set; includeNoProject: boolean; + projectStatus: Set; label: Set; /** Property definition id → fixed member keys (`propertyFilterValueKey`). */ property: Map>; @@ -78,6 +80,11 @@ export function baselineFromQuery(query: Record): IssueViewBase const assigneeFilters = actorArray(query.assigneeFilters); const creatorFilters = actorArray(query.creatorFilters); const projectFilters = stringArray(query.projectFilters); + // A saved view predating this dimension has no key at all, and an unknown + // member cannot be represented in the store — both collapse to "no filter". + const projectStatusFilters = stringArray(query.projectStatusFilters).filter( + (s): s is ProjectStatus => (PROJECT_STATUS_ORDER as readonly string[]).includes(s), + ); const labelFilters = stringArray(query.labelFilters); const includeNoAssignee = query.includeNoAssignee === true; const includeNoProject = query.includeNoProject === true; @@ -104,6 +111,7 @@ export function baselineFromQuery(query: Record): IssueViewBase creator: new Set(creatorFilters.map(actorFilterKey)), project: new Set(projectFilters), includeNoProject, + projectStatus: new Set(projectStatusFilters), label: new Set(labelFilters), property, raw: { @@ -114,6 +122,7 @@ export function baselineFromQuery(query: Record): IssueViewBase creatorFilters, projectFilters, includeNoProject, + projectStatusFilters, labelFilters, propertyFilters, }, diff --git a/packages/core/issues/index.ts b/packages/core/issues/index.ts index 21030fb925c..e26ef2d7784 100644 --- a/packages/core/issues/index.ts +++ b/packages/core/issues/index.ts @@ -19,3 +19,4 @@ export { type StatusFilterColumnsResult, normalizeStatusPatch, } from "./status-category"; +export * from "./wakeups"; diff --git a/packages/core/issues/mutations.ts b/packages/core/issues/mutations.ts index fe145509baa..a2cdaf0ac41 100644 --- a/packages/core/issues/mutations.ts +++ b/packages/core/issues/mutations.ts @@ -37,7 +37,7 @@ import { } from "./delete-cache"; import { useWorkspaceId } from "../hooks"; import { useRecentContextStore } from "../chat/recent-context-store"; -import { useRecentIssuesStore } from "./stores"; +import { useRecentIssuesStore, useTaskSupplementDraftStore } from "./stores"; import type { InboxItem, Issue, IssueReaction } from "../types"; import type { CreateCommentSubIssueManualRequest, @@ -206,6 +206,7 @@ export function useUpdateIssue() { // a rapid follow-up edit. mutationFn still sends the full payload. const { suppress_run: _suppressRun, + duplicate_of_issue_id: _duplicateOfIssueId, description: _description, description_base: _descriptionBase, title_base: _titleBase, @@ -317,6 +318,7 @@ export function useUpdateIssue() { // is the plain surgical patch it always was. const { suppress_run: _suppressRun, + duplicate_of_issue_id: _duplicateOfIssueId, description_base: _descriptionBase, move_intent: _moveIntent, id: _id, @@ -377,6 +379,14 @@ export function useUpdateIssue() { if (vars.attachment_ids?.length) { qc.invalidateQueries({ queryKey: issueKeys.attachments(vars.id) }); } + // A duplicate mark is not on Issue; refresh both sides now rather than + // waiting for the realtime echo. + if (vars.duplicate_of_issue_id) { + qc.invalidateQueries({ queryKey: issueKeys.duplicates(wsId, vars.id) }); + qc.invalidateQueries({ + queryKey: issueKeys.duplicates(wsId, vars.duplicate_of_issue_id), + }); + } // Invalidate old parent's children cache if (ctx?.parentId) { qc.invalidateQueries({ @@ -1198,6 +1208,60 @@ export function useCancelIssueRun(issueId: string) { }); } +export function useCreateTaskSupplement(issueId: string) { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ taskId, content, clientRequestId }: { + taskId: string; + content: string; + clientRequestId: string; + }) => api.createTaskSupplement(issueId, taskId, content, clientRequestId), + onSuccess: (comment, { taskId, clientRequestId }) => { + // Re-anchoring the run can unmount its composer before this response. + // Clear the submitted draft here, without discarding any newer edits. + const drafts = useTaskSupplementDraftStore.getState(); + if (drafts.drafts[taskId]?.clientRequestId === clientRequestId) { + drafts.clear(taskId); + } + const entry: TimelineEntry = { + type: "comment", + id: comment.id, + actor_type: comment.author_type, + actor_id: comment.author_id, + content: comment.content, + parent_id: comment.parent_id, + comment_type: comment.type, + reactions: comment.reactions ?? [], + attachments: comment.attachments ?? [], + created_at: comment.created_at, + updated_at: comment.updated_at, + supplement_task_id: comment.supplement_task_id, + supplement_status: comment.supplement_status, + supplement_failure_reason: comment.supplement_failure_reason, + supplement_delivered_at: comment.supplement_delivered_at, + }; + client.setQueryData(issueKeys.timeline(issueId), (old) => { + if (!old) return [entry]; + if (old.some((item) => item.id === entry.id)) return old; + return sortTimelineEntriesAsc([...old, entry]); + }); + client.invalidateQueries({ queryKey: issueKeys.tasks(issueId) }); + }, + }); +} + +export function useRetryTaskSupplement(issueId: string) { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ taskId, commentId }: { taskId: string; commentId: string }) => + api.retryTaskSupplement(issueId, taskId, commentId), + onSettled: () => { + client.invalidateQueries({ queryKey: issueKeys.timeline(issueId) }); + client.invalidateQueries({ queryKey: issueKeys.tasks(issueId) }); + }, + }); +} + export function useRetryIssueRun(issueId: string) { const client = useQueryClient(); return useMutation({ diff --git a/packages/core/issues/queries.test.ts b/packages/core/issues/queries.test.ts index a8b2fdcb4ff..92470aa575d 100644 --- a/packages/core/issues/queries.test.ts +++ b/packages/core/issues/queries.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; @@ -555,16 +556,51 @@ describe("issueIdentifierOptions", () => { ).rejects.toThrow("boom"); }); - it("passes the query's abort signal down so unmount cancels the lookup", async () => { - const getIssue = vi - .fn<(id: string, options?: { signal?: AbortSignal }) => Promise>() - .mockResolvedValue(makeIssue(7)); - installFakeIssueApi(getIssue); - - await qc.fetchQuery(issueIdentifierOptions(WS_ID, "MUL-7")); - - expect(getIssue.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal); - }); + it.each(["found", "missing"] as const)( + "shares a pending %s lookup across rapid observer remounts and caches its result", + async (outcome) => { + let resolveLookup!: (issue: Issue) => void; + let rejectLookup!: (error: Error) => void; + let abortedLookups = 0; + const getIssue = vi + .fn<(id: string, options?: { signal?: AbortSignal }) => Promise>() + .mockImplementation((_id, options) => new Promise((resolve, reject) => { + resolveLookup = resolve; + rejectLookup = reject; + options?.signal?.addEventListener("abort", () => { + abortedLookups++; + reject(new DOMException("Unmounted", "AbortError")); + }, { once: true }); + })); + installFakeIssueApi(getIssue); + + const options = issueIdentifierOptions(WS_ID, "MUL-7"); + // Streaming rich content can remove the last mention observer before + // its response arrives, then render that same identifier again. + for (let i = 0; i < 20; i++) { + const observer = new QueryObserver(qc, options); + const unsubscribe = observer.subscribe(() => {}); + unsubscribe(); + } + + expect(getIssue).toHaveBeenCalledTimes(1); + expect(abortedLookups).toBe(0); + const completed = qc.fetchQuery(options); + if (outcome === "found") { + resolveLookup(makeIssue(7)); + } else { + rejectLookup(new ApiError("issue not found", 404, "Not Found")); + } + const expected = outcome === "found" ? makeIssue(7) : null; + await expect(completed).resolves.toEqual(expected); + + const observer = new QueryObserver(qc, options); + const unsubscribe = observer.subscribe(() => {}); + expect(observer.getCurrentResult().data).toEqual(expected); + expect(getIssue).toHaveBeenCalledTimes(1); + unsubscribe(); + }, + ); it("keys the query by workspace and identifier", () => { expect(issueKeys.identifier(WS_ID, "MUL-7")).toEqual([ diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index 706fb1a5358..b7c968abd21 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -130,6 +130,11 @@ export const issueKeys = { /** Resolve a bare issue identifier (e.g. "MUL-123") to an issue. */ identifier: (wsId: string, identifier: string) => [...issueKeys.all(wsId), "identifier", identifier] as const, + /** Prefix for every per-issue duplicate-relation query in a workspace. */ + duplicatesAll: (wsId: string) => + [...issueKeys.all(wsId), "duplicates"] as const, + duplicates: (wsId: string, id: string) => + [...issueKeys.duplicatesAll(wsId), id] as const, /** Prefix for every per-parent children query in a workspace. */ childrenAll: (wsId: string) => [...issueKeys.all(wsId), "children"] as const, @@ -460,13 +465,15 @@ export function issueDetailOptions(wsId: string, id: string) { export function issueIdentifierOptions(wsId: string, identifier: string) { return queryOptions({ queryKey: issueKeys.identifier(wsId, identifier), - queryFn: async ({ signal }) => { + // Keep this small, cacheable lookup alive when the last mention unmounts. + // A remount can then share its request instead of aborting and restarting it. + queryFn: async () => { try { - return await api.getIssue(identifier, { signal }); + return await api.getIssue(identifier); } catch (err) { // Unknown identifier / wrong workspace prefix → render as plain text. - // Any other failure (401/5xx/abort) must keep propagating so the query - // is retried or cancelled instead of being cached as "no such issue". + // Any other failure (401/5xx) must keep propagating so the query + // can retry instead of being cached as "no such issue". if (err instanceof ApiError && err.status === 404) return null; throw err; } @@ -491,6 +498,18 @@ export function childIssueProgressOptions(wsId: string) { }); } +/** Both sides of an issue's duplicate relation: its original and its duplicates. */ +export function issueDuplicatesOptions(wsId: string, id: string) { + return queryOptions({ + queryKey: issueKeys.duplicates(wsId, id), + queryFn: () => api.listIssueDuplicates(id), + // Same reason as childIssuesOptions: a mark written while this workspace + // is not the active realtime subscription would otherwise leave the + // Infinity-stale snapshot wrong when the issue is opened again. + refetchOnMount: "always", + }); +} + export function childIssuesOptions(wsId: string, id: string) { return queryOptions({ queryKey: issueKeys.children(wsId, id), diff --git a/packages/core/issues/stores/index.ts b/packages/core/issues/stores/index.ts index 20fa75e6733..d7218152403 100644 --- a/packages/core/issues/stores/index.ts +++ b/packages/core/issues/stores/index.ts @@ -28,6 +28,7 @@ export { } from "./resolved-expand-store"; export { useCommentComposerStore } from "./comment-composer-store"; export { useCommentDraftStore, type CommentDraftKey } from "./comment-draft-store"; +export { useTaskSupplementDraftStore, type TaskSupplementDraft } from "./task-supplement-draft-store"; export { myIssuesViewStore, type MyIssuesViewState, diff --git a/packages/core/issues/stores/task-supplement-draft-store.test.ts b/packages/core/issues/stores/task-supplement-draft-store.test.ts new file mode 100644 index 00000000000..5fc88848f2e --- /dev/null +++ b/packages/core/issues/stores/task-supplement-draft-store.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from "vitest"; +import { useTaskSupplementDraftStore } from "./task-supplement-draft-store"; + +describe("task supplement draft store", () => { + beforeEach(() => useTaskSupplementDraftStore.setState({ drafts: {} })); + + it.each(["", " \n "])("clears an empty terminal draft (%j)", (content) => { + const store = useTaskSupplementDraftStore.getState(); + store.setContent("task-1", "issue-1", content); + store.markEnded("task-1"); + expect(useTaskSupplementDraftStore.getState().drafts["task-1"]).toBeUndefined(); + }); + + it("clears a previously ended draft after its text is erased", () => { + const store = useTaskSupplementDraftStore.getState(); + store.setContent("task-1", "issue-1", "retained"); + store.markEnded("task-1"); + store.setContent("task-1", "issue-1", ""); + store.markEnded("task-1"); + expect(useTaskSupplementDraftStore.getState().drafts["task-1"]).toBeUndefined(); + }); + + it("does not let another task consume or clear the draft", () => { + const store = useTaskSupplementDraftStore.getState(); + store.setContent("task-1", "issue-1", "first"); + store.setContent("task-2", "issue-1", "second"); + store.clear("task-2"); + expect(useTaskSupplementDraftStore.getState().drafts["task-1"]?.content).toBe("first"); + }); + + it("changes request identity only when the user changes text", () => { + const store = useTaskSupplementDraftStore.getState(); + store.setContent("task-1", "issue-1", "same text"); + store.setRequestId("task-1", "request-1"); + store.markEnded("task-1"); + expect(useTaskSupplementDraftStore.getState().drafts["task-1"]).toMatchObject({ + content: "same text", clientRequestId: "request-1", ended: true, + }); + store.setContent("task-1", "issue-1", "edited text"); + expect(useTaskSupplementDraftStore.getState().drafts["task-1"]?.clientRequestId).toBeUndefined(); + }); +}); diff --git a/packages/core/issues/stores/task-supplement-draft-store.ts b/packages/core/issues/stores/task-supplement-draft-store.ts new file mode 100644 index 00000000000..85f893fd243 --- /dev/null +++ b/packages/core/issues/stores/task-supplement-draft-store.ts @@ -0,0 +1,106 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import { registerDraftCleanup } from "../../drafts/cleanup-registry"; +import { defaultStorage } from "../../platform/storage"; +import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage"; + +export interface TaskSupplementDraft { + issueId: string; + content: string; + open: boolean; + ended: boolean; + clientRequestId?: string; + updatedAt: number; +} + +interface TaskSupplementDraftStore { + drafts: Record; + open: (taskId: string, issueId: string) => void; + setContent: (taskId: string, issueId: string, content: string) => void; + setRequestId: (taskId: string, clientRequestId: string) => void; + markEnded: (taskId: string) => void; + clear: (taskId: string) => void; +} + +const TTL_MS = 30 * 24 * 60 * 60 * 1000; + +function prune(drafts: Record): Record { + const cutoff = Date.now() - TTL_MS; + return Object.fromEntries(Object.entries(drafts).filter(([, draft]) => + draft.updatedAt >= cutoff && (!!draft.content.trim() || draft.open), + )); +} + +export const useTaskSupplementDraftStore = create()( + persist( + (set) => ({ + drafts: {}, + open: (taskId, issueId) => set((state) => ({ + drafts: { + ...state.drafts, + [taskId]: { + issueId, + content: state.drafts[taskId]?.content ?? "", + clientRequestId: state.drafts[taskId]?.clientRequestId, + ended: state.drafts[taskId]?.ended ?? false, + open: true, + updatedAt: Date.now(), + }, + }, + })), + setContent: (taskId, issueId, content) => set((state) => ({ + drafts: { + ...state.drafts, + [taskId]: { + issueId, + content, + open: true, + ended: state.drafts[taskId]?.ended ?? false, + // Editing creates a new logical request; retries of unchanged text + // keep the prior id through setRequestId instead. + clientRequestId: undefined, + updatedAt: Date.now(), + }, + }, + })), + setRequestId: (taskId, clientRequestId) => set((state) => { + const draft = state.drafts[taskId]; + if (!draft) return state; + return { drafts: { ...state.drafts, [taskId]: { ...draft, clientRequestId, updatedAt: Date.now() } } }; + }), + markEnded: (taskId) => set((state) => { + const draft = state.drafts[taskId]; + if (!draft) return state; + if (!draft.content.trim()) { + const drafts = { ...state.drafts }; + delete drafts[taskId]; + return { drafts }; + } + if (draft.ended) return state; + return { drafts: { ...state.drafts, [taskId]: { ...draft, open: true, ended: true, updatedAt: Date.now() } } }; + }), + clear: (taskId) => set((state) => { + if (!(taskId in state.drafts)) return state; + const drafts = { ...state.drafts }; + delete drafts[taskId]; + return { drafts }; + }), + }), + { + name: "multica_task_supplement_drafts", + storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)), + merge: (persisted, current) => ({ + ...current, + drafts: prune((persisted as { drafts?: Record } | undefined)?.drafts ?? {}), + }), + }, + ), +); + +registerForWorkspaceRehydration(() => useTaskSupplementDraftStore.persist.rehydrate()); + +registerDraftCleanup({ + storageKey: "multica_task_supplement_drafts", + workspaceScoped: true, + resetInMemory: () => useTaskSupplementDraftStore.setState({ drafts: {} }), +}); diff --git a/packages/core/issues/stores/view-store-project-status.test.ts b/packages/core/issues/stores/view-store-project-status.test.ts new file mode 100644 index 00000000000..56f9d90303c --- /dev/null +++ b/packages/core/issues/stores/view-store-project-status.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment node +import { describe, expect, it, beforeEach } from "vitest"; +import { createStore, type StoreApi } from "zustand/vanilla"; +import { + mergeViewStatePersisted, + viewStoreSlice, + type IssueViewState, +} from "./view-store"; + +function defaults(): IssueViewState { + const store = createStore()((set) => viewStoreSlice(set)); + return store.getState(); +} + +describe("projectStatusFilters", () => { + let store: StoreApi; + beforeEach(() => { + store = createStore()((set) => viewStoreSlice(set)); + }); + + it("toggles a status on and off", () => { + store.getState().toggleProjectStatusFilter("in_progress"); + store.getState().toggleProjectStatusFilter("planned"); + expect(store.getState().projectStatusFilters).toEqual([ + "in_progress", + "planned", + ]); + store.getState().toggleProjectStatusFilter("in_progress"); + expect(store.getState().projectStatusFilters).toEqual(["planned"]); + }); + + it("clears with its own dimension and with clearFilters", () => { + store.getState().toggleProjectStatusFilter("paused"); + store.getState().clearFilterDimension("projectStatus"); + expect(store.getState().projectStatusFilters).toEqual([]); + + store.getState().toggleProjectStatusFilter("paused"); + store.getState().clearFilters(); + expect(store.getState().projectStatusFilters).toEqual([]); + }); + + it("leaves the project-id dimension alone", () => { + store.getState().toggleProjectFilter("p-1"); + store.getState().toggleProjectStatusFilter("completed"); + store.getState().clearFilterDimension("projectStatus"); + expect(store.getState().projectFilters).toEqual(["p-1"]); + }); +}); + +// `seedIssueSurfaceViewState` merges a saved view's server-owned jsonb blob +// straight into the store, and a persisted snapshot can be hand-edited. A +// member the client cannot represent would take a 400 from the backend and +// throw in the filter chip, which resolves its dot through +// PROJECT_STATUS_CONFIG. +describe("mergeViewStatePersisted project statuses", () => { + it("keeps known statuses", () => { + const merged = mergeViewStatePersisted( + { projectStatusFilters: ["in_progress", "cancelled"] }, + defaults(), + ); + expect(merged.projectStatusFilters).toEqual(["in_progress", "cancelled"]); + }); + + it("drops members the store cannot represent", () => { + const merged = mergeViewStatePersisted( + // "backlog" is an issue status; the project lifecycle has no such value. + { projectStatusFilters: ["in_progress", "backlog", 7, null] }, + defaults(), + ); + expect(merged.projectStatusFilters).toEqual(["in_progress"]); + }); + + it("falls back to the default for a snapshot saved before the dimension", () => { + const merged = mergeViewStatePersisted({ projectFilters: ["p-1"] }, defaults()); + expect(merged.projectStatusFilters).toEqual([]); + }); + + it("treats a non-array value as no filter", () => { + const merged = mergeViewStatePersisted( + { projectStatusFilters: "in_progress" }, + defaults(), + ); + expect(merged.projectStatusFilters).toEqual([]); + }); +}); diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index 53dd6a49f76..4f6ed533d73 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -4,7 +4,8 @@ import { useEffect, useRef } from "react"; import { create } from "zustand"; import { createStore, type StoreApi } from "zustand/vanilla"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { IssueStatus, IssuePriority, PropertyFilterValue } from "../../types"; +import type { IssueStatus, IssuePriority, ProjectStatus, PropertyFilterValue } from "../../types"; +import { PROJECT_STATUS_ORDER } from "../../projects/config"; import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage"; import { defaultStorage } from "../../platform/storage"; @@ -116,7 +117,7 @@ export interface ActorFilterValue { id: string; } -/** The nine query-defining filter fields as one value — what a saved view +/** The ten query-defining filter fields as one value — what a saved view * fixes, and what resets restore. */ export interface FilterSnapshot { statusFilters: IssueStatus[]; @@ -126,6 +127,7 @@ export interface FilterSnapshot { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; + projectStatusFilters: ProjectStatus[]; labelFilters: string[]; propertyFilters: Record; } @@ -138,6 +140,7 @@ export type FilterDimension = | "assignee" | "creator" | "project" + | "projectStatus" | "label" | `property:${string}`; @@ -243,6 +246,13 @@ export interface IssueViewState { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; + /** + * Lifecycle status of the parent project. Its own dimension next to + * `projectFilters` (AND across the two, OR within): "show me everything in + * the projects that are in progress" without naming them one by one. An + * issue with no project never matches. + */ + projectStatusFilters: ProjectStatus[]; labelFilters: string[]; /** * Custom-property filters: definition id → selected values (checkbox @@ -312,6 +322,7 @@ export interface IssueViewState { toggleCreatorFilter: (value: ActorFilterValue) => void; toggleProjectFilter: (projectId: string) => void; toggleNoProject: () => void; + toggleProjectStatusFilter: (status: ProjectStatus) => void; toggleLabelFilter: (labelId: string) => void; togglePropertyFilter: (propertyId: string, optionId: string) => void; /** Replace a property's full filter value set (used by scalar value inputs @@ -360,6 +371,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, + projectStatusFilters: [], labelFilters: [], propertyFilters: {}, dateFilter: null, @@ -453,6 +465,12 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue })), toggleNoProject: () => set((state) => ({ includeNoProject: !state.includeNoProject })), + toggleProjectStatusFilter: (status) => + set((state) => ({ + projectStatusFilters: state.projectStatusFilters.includes(status) + ? state.projectStatusFilters.filter((s) => s !== status) + : [...state.projectStatusFilters, status], + })), toggleLabelFilter: (labelId) => set((state) => ({ labelFilters: state.labelFilters.includes(labelId) @@ -499,6 +517,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, + projectStatusFilters: [], labelFilters: [], propertyFilters: {}, dateFilter: null, @@ -518,6 +537,8 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue return { creatorFilters: [] }; case "project": return { projectFilters: [], includeNoProject: false }; + case "projectStatus": + return { projectStatusFilters: [] }; case "label": return { labelFilters: [] }; default: { @@ -653,6 +674,7 @@ export const viewStorePersistOptions = (name: string) => ({ creatorFilters: state.creatorFilters, projectFilters: state.projectFilters, includeNoProject: state.includeNoProject, + projectStatusFilters: state.projectStatusFilters, labelFilters: state.labelFilters, propertyFilters: state.propertyFilters, sortBy: state.sortBy, @@ -766,6 +788,16 @@ export function mergeViewStatePersisted( tableCollapsedParents: Array.isArray(p.tableCollapsedParents) ? p.tableCollapsedParents : current.tableCollapsedParents, + // A saved view is a server-owned blob and a persisted snapshot can be + // hand-edited, so an unknown member can arrive here. It cannot be + // represented: the backend rejects it with a 400 and the filter chip + // resolves its dot through PROJECT_STATUS_CONFIG. Drop it, like + // `baselineFromQuery` does on the read side. + projectStatusFilters: Array.isArray(p.projectStatusFilters) + ? p.projectStatusFilters.filter((status): status is ProjectStatus => + (PROJECT_STATUS_ORDER as readonly string[]).includes(status as string), + ) + : current.projectStatusFilters, }; return { ...merged, diff --git a/packages/core/issues/wakeups.ts b/packages/core/issues/wakeups.ts new file mode 100644 index 00000000000..f8b42452c81 --- /dev/null +++ b/packages/core/issues/wakeups.ts @@ -0,0 +1,134 @@ +import { + queryOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import type { WorkspaceWakeupFilters } from "../types"; +import { api } from "../api"; +import { issueKeys } from "./queries"; + +export function workspaceWakeupSummariesOptions(workspaceId: string) { + return queryOptions({ + queryKey: ["issue-wakeup-summaries", workspaceId], + queryFn: () => api.listIssueWakeupSummaries(), + enabled: !!workspaceId, + staleTime: 10_000, + }); +} + +export function issueWakeupsOptions(workspaceId: string, issueId: string) { + return queryOptions({ + queryKey: ["issue-wakeups", workspaceId, issueId], + queryFn: () => api.listIssueWakeups(issueId), + enabled: !!workspaceId && !!issueId, + refetchInterval: 10_000, + }); +} + +export function useDisableIssueWakeup(workspaceId: string, issueId: string) { + const client = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.disableIssueWakeup(issueId, id), + onSettled: async () => { + await Promise.all([ + client.invalidateQueries({ + queryKey: ["workspace-wakeups", workspaceId], + }), + client.invalidateQueries({ + queryKey: issueWakeupsOptions(workspaceId, issueId).queryKey, + }), + client.invalidateQueries({ + queryKey: workspaceWakeupSummariesOptions(workspaceId).queryKey, + }), + client.invalidateQueries({ queryKey: issueKeys.tasks(issueId) }), + ]); + }, + }); +} + +export function useEnableIssueWakeup(workspaceId: string, issueId: string) { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + ...input + }: { + id: string; + revision: number; + at?: string; + rearm?: boolean; + }) => api.enableIssueWakeup(issueId, id, input), + onSettled: async () => { + await Promise.all([ + client.invalidateQueries({ + queryKey: ["workspace-wakeups", workspaceId], + }), + client.invalidateQueries({ + queryKey: issueWakeupsOptions(workspaceId, issueId).queryKey, + }), + client.invalidateQueries({ + queryKey: workspaceWakeupSummariesOptions(workspaceId).queryKey, + }), + client.invalidateQueries({ queryKey: issueKeys.tasks(issueId) }), + ]); + }, + }); +} + +export function useEditWakeupInstruction(workspaceId: string, issueId: string) { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...input }: { id: string; instruction: string; expected_instruction: string; revision: number }) => + api.editIssueWakeupInstruction(issueId, id, input), + onSettled: async () => { + await Promise.all([ + client.invalidateQueries({ queryKey: ["workspace-wakeups", workspaceId] }), + client.invalidateQueries({ queryKey: issueWakeupsOptions(workspaceId, issueId).queryKey }), + ]); + }, + }); +} + +export function workspaceWakeupsOptions( + workspaceId: string, + filters: WorkspaceWakeupFilters, +) { + return queryOptions({ + queryKey: ["workspace-wakeups", workspaceId, filters], + queryFn: () => api.listWorkspaceWakeups(filters), + enabled: !!workspaceId, + refetchInterval: 10_000, + }); +} + +export function useDisableWorkspaceWakeups(workspaceId: string) { + const client = useQueryClient(); + return useMutation({ + mutationFn: async (rows: { id: string; issue_id: string }[]) => { + const failed: string[] = []; + // Sequential requests bound load and retain precise partial-failure results. + for (const row of rows) { + try { + await api.disableIssueWakeup(row.issue_id, row.id); + } catch { + failed.push(row.id); + } + } + return { failed, succeeded: rows.length - failed.length }; + }, + onSettled: async (_data, _error, rows) => { + await Promise.all([ + client.invalidateQueries({ + queryKey: ["workspace-wakeups", workspaceId], + }), + client.invalidateQueries({ queryKey: ["issue-wakeups", workspaceId] }), + client.invalidateQueries({ + queryKey: ["issue-wakeup-summaries", workspaceId], + }), + ...Array.from(new Set(rows.map((r) => r.issue_id))).map((id) => + client.invalidateQueries({ queryKey: issueKeys.tasks(id) }), + ), + ]); + }, + }); +} diff --git a/packages/core/issues/ws-updaters.ts b/packages/core/issues/ws-updaters.ts index d5fc0f400df..fa661caeb7d 100644 --- a/packages/core/issues/ws-updaters.ts +++ b/packages/core/issues/ws-updaters.ts @@ -753,12 +753,31 @@ export function invalidatePropertyWindowQueries(qc: QueryClient, wsId: string) { }); } +/** + * Refreshes the duplicate relations an issue:updated event touched: the issue + * itself and the originals it was marked against before and after the write. + */ +export function onIssueDuplicateMarkChanged( + qc: QueryClient, + wsId: string, + issueId: string, + next: string | null | undefined, + prev: string | null | undefined, +) { + if ((next ?? null) === (prev ?? null)) return; + for (const id of [issueId, next, prev]) { + if (id) qc.invalidateQueries({ queryKey: issueKeys.duplicates(wsId, id) }); + } +} + export function onIssueDeleted( qc: QueryClient, wsId: string, issueId: string, ) { cleanupDeletedIssueCaches(qc, wsId, issueId); + // Deleting an original clears its duplicates' marks server-side. + qc.invalidateQueries({ queryKey: issueKeys.duplicatesAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) }); qc.invalidateQueries({ queryKey: projectKeys.all(wsId) }); diff --git a/packages/core/markdown/index.ts b/packages/core/markdown/index.ts index c33ceefbeb8..c24cb091637 100644 --- a/packages/core/markdown/index.ts +++ b/packages/core/markdown/index.ts @@ -1 +1,2 @@ export { preprocessMentionShortcodes } from "./mention-shortcodes"; +export { isMentionBoundaryAfter } from "./mention-boundary"; diff --git a/packages/core/markdown/mention-boundary.test.ts b/packages/core/markdown/mention-boundary.test.ts new file mode 100644 index 00000000000..72c1612daec --- /dev/null +++ b/packages/core/markdown/mention-boundary.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { isMentionBoundaryAfter } from "./mention-boundary"; + +// The character-level matrix for the shared mention boundary rule. The wiring — +// that the editor and the mobile composer actually consult it — is covered +// where each of them lives (mention-boundary.test.ts under packages/views and +// mention-serialize.test.ts under apps/mobile). + +/** The `@` is placed directly after this text, so its last code point decides. */ +const OPENS: Array<[string, string]> = [ + ["nothing at all", ""], + ["a half-width space", "hello "], + // A Chinese IME inserts U+3000 for the space key. + ["a full-width space", "你好 "], + ["a tab", "hello\t"], + ["a newline", "hello\n"], + ["punctuation", "hello("], + ["a CJK word with no separator", "你好"], + ["a hiragana word", "こんにちは"], + ["a katakana word", "テレビ"], + // U+30FC carries Katakana only as a Script_Extensions value, so it needs the + // explicit listing in SPACELESS_SCRIPT to count as part of the word. + ["a katakana word ending in the prolonged sound mark", "コーヒー"], + ["half-width katakana", "コーヒー"], + ["a hangul word", "안녕하세요"], + ["a thai word", "สวัสดี"], + ["a lao word", "ສະບາຍດີ"], + ["a khmer word", "ជំរាបសួរ"], + ["a myanmar word", "မင်္ဂလာပါ"], + ["a tibetan word", "བཀྲ་ཤིས"], + ["an emoji", "🎉"], +]; + +const SHUT: Array<[string, string]> = [ + ["an ASCII word", "hello"], + ["an ASCII capital", "Hello"], + ["a digit", "2024"], + ["an underscore", "snake_case"], + ["an accented latin word", "café"], + ["a spanish word", "josé"], + ["a cyrillic word", "почта"], + ["a greek word", "αλφα"], + ["a vietnamese word", "chà"], + ["a dotted domain", "user@example.com"], + ["a full ASCII address", "first.last@example.co.uk"], +]; + +describe("isMentionBoundaryAfter", () => { + it.each(OPENS)("opens after %s", (_name, before) => { + expect(isMentionBoundaryAfter(before)).toBe(true); + }); + + it.each(SHUT)("stays shut after %s", (_name, before) => { + expect(isMentionBoundaryAfter(before)).toBe(false); + }); + + it("reads only the last code point", () => { + // The caller may hand over a two-unit tail; earlier characters are noise. + expect(isMentionBoundaryAfter("café")).toBe(false); + expect(isMentionBoundaryAfter("é")).toBe(false); + }); + + it("reads a code point outside the BMP whole", () => { + // Deseret is a letter and not a spaceless script, so it stays shut — but + // only if the two units arrive together. Read one unit at a time the + // implementation would see a lone surrogate, fail both tests, and open. + const astral = "𐐀"; + expect(astral.length).toBe(2); + expect(isMentionBoundaryAfter(astral)).toBe(false); + }); +}); diff --git a/packages/core/markdown/mention-boundary.ts b/packages/core/markdown/mention-boundary.ts new file mode 100644 index 00000000000..3794b0bfdd1 --- /dev/null +++ b/packages/core/markdown/mention-boundary.ts @@ -0,0 +1,66 @@ +/** + * Where an `@` starts a mention token rather than continuing the word before it. + * + * Single source of truth for both composers: the web/desktop editor + * (packages/views/editor/extensions/mention-suggestion.tsx) and the mobile + * comment composer (apps/mobile/lib/mention-serialize.ts). They have to agree — + * the same text must offer the picker on every client. + * + * Tiptap's own rule is `allowedPrefixes`, defaulting to `[" "]`: a half-width + * space and nothing else. That makes a mention unreachable in the two ways CJK + * text is actually typed — with no separator at all, and after the full-width + * space (U+3000) an IME inserts. The rule it means to protect is narrower than + * "not a space": an `@` glued to the end of a word, as in `user@example.com`. + * So the question is whether the character before the `@` is part of a word. + * + * Two things make that question non-obvious: + * + * - Word characters are Unicode letters, digits, marks and `_`. An ASCII-only + * class makes every accented, Cyrillic or Greek letter look like a + * boundary, which re-opens the address case for `josé@example.com`, + * `почта@mail.ru` and `αλφα@example.com`. + * - Scripts written without spaces between words are the exception: there is + * no separator to type, so the `好` in `你好@Mi` is where the token starts. + * + * Known ambiguity, accepted rather than solved: `用户@example.com` cannot be + * told apart from a mention typed with no separator, and opens the picker. No + * character-level rule that keeps `你好@Mi` working can distinguish the two. + * + * Pure — no IO, no global state. + */ + +/** Unicode word characters. An `@` glued to one of these continues a word. */ +const WORD_CHARACTER = /[\p{L}\p{N}\p{M}_]/u; + +/** + * Scripts that do not separate words with spaces, where a word can therefore + * end directly against the `@`. Latin, Cyrillic, Greek and friends are absent + * on purpose: their writers type a space before `@`, and that absence is also + * what keeps their email addresses from opening the picker. + * + * Written with `Script=`, not `Script_Extensions=`, because Hermes — the JS + * engine the mobile app runs on — rejects `\p{Script_Extensions=Thai}` (also + * Lao, Khmer and Tibetan) as an invalid property name and fails to parse the + * module, which would take the app down with it. The escapes trailing the + * scripts are the code points that carry one of these scripts only as an + * extension *and* are word characters, so a `Script=` test alone would read + * them as word continuations: U+3099/U+309A and U+FF9E/U+FF9F voiced sound + * marks, and U+30FC/U+FF70 prolonged sound marks — the last character of + * `コーヒー`, which would otherwise not open the picker. + */ +const SPACELESS_SCRIPT = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}\p{Script=Tibetan}\u3099\u309A\u30FC\uFF70\uFF9E\uFF9F]/u; + +/** + * True when an `@` typed immediately after `before` starts a token. + * + * `before` is the text preceding the `@`; only its last code point is read, and + * an empty string means the `@` opens the text. Callers pass at most two code + * units so an astral-plane code point arrives whole. + */ +export function isMentionBoundaryAfter(before: string): boolean { + const codePoints = [...before]; + const previous = codePoints[codePoints.length - 1]; + if (previous === undefined) return true; + return SPACELESS_SCRIPT.test(previous) || !WORD_CHARACTER.test(previous); +} diff --git a/packages/core/modals/store.ts b/packages/core/modals/store.ts index 01ccfabc580..3c9664438b7 100644 --- a/packages/core/modals/store.ts +++ b/packages/core/modals/store.ts @@ -9,6 +9,7 @@ type ModalType = | "create-squad" | "feedback" | "issue-set-parent" + | "issue-mark-duplicate" | "issue-add-child" | "issue-delete-confirm" | "issue-run-confirm" diff --git a/packages/core/package.json b/packages/core/package.json index 0d737474018..09c5537723e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,6 +37,7 @@ "./issues/batch": "./issues/batch.ts", "./issues/canonical-id": "./issues/canonical-id.ts", "./issues/queries": "./issues/queries.ts", + "./issues/wakeups": "./issues/wakeups.ts", "./issues/mutations": "./issues/mutations.ts", "./issues/timeline-sort": "./issues/timeline-sort.ts", "./issues/comment-deletion": "./issues/comment-deletion.ts", diff --git a/packages/core/paths/reserved-slugs.ts b/packages/core/paths/reserved-slugs.ts index 50bf803db1d..a5b9ca1b6b3 100644 --- a/packages/core/paths/reserved-slugs.ts +++ b/packages/core/paths/reserved-slugs.ts @@ -53,6 +53,7 @@ export const RESERVED_SLUGS: ReadonlySet = new Set([ "support", "status", "legal", + "licensing", "privacy", "terms", "security", diff --git a/packages/core/platform/auth-initializer.tsx b/packages/core/platform/auth-initializer.tsx index 70d8c1014a0..c768b2c8c1c 100644 --- a/packages/core/platform/auth-initializer.tsx +++ b/packages/core/platform/auth-initializer.tsx @@ -94,6 +94,11 @@ export function AuthInitializer({ .setAgentConversationStartersSupported( cfg.agent_conversation_starters_supported === true, ); + configStore + .getState() + .setIssueCreatePropertiesSupported( + cfg.issue_create_properties_supported === true, + ); // Older servers delete a comment's replies with it; promise nothing // about replies unless the server declares otherwise. configStore diff --git a/packages/core/projects/mutations.test.tsx b/packages/core/projects/mutations.test.tsx index 0db7e47006b..d37b74d0b17 100644 --- a/packages/core/projects/mutations.test.tsx +++ b/packages/core/projects/mutations.test.tsx @@ -12,7 +12,8 @@ import { getIssueSurfaceViewStore, pruneIssueSurfaceViewStates, } from "../issues/stores/surface-view-store"; -import { useDeleteProject } from "./mutations"; +import { issueKeys } from "../issues/queries"; +import { useDeleteProject, useUpdateProject } from "./mutations"; vi.mock("../hooks", () => ({ useWorkspaceId: () => "ws-1", @@ -58,4 +59,64 @@ describe("useDeleteProject", () => { expect(deleteProject).toHaveBeenCalledWith("p1"); expect(store.getState().viewMode).toBe("board"); }); + + // Regression: the issue-table invalidation once sat on the create + // mutation, so a missed realtime event left a project-status-filtered + // window showing the deleted project's issues (staleTime is Infinity). + it("invalidates the issue table windows", async () => { + const tableKey = [...issueKeys.tableAll("ws-1"), "window"]; + qc.setQueryData(tableKey, { rows: [] }); + + const { result } = renderHook(() => useDeleteProject(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync("p1"); + }); + + expect(qc.getQueryState(tableKey)?.isInvalidated).toBe(true); + }); +}); + +describe("useUpdateProject", () => { + let qc: QueryClient; + let updateProject: ReturnType; + const tableKey = [...issueKeys.tableAll("ws-1"), "window"]; + + beforeEach(() => { + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + updateProject = vi.fn().mockResolvedValue({ id: "p1" }); + setApiInstance({ updateProject } as unknown as ApiClient); + qc.setQueryData(tableKey, { rows: [] }); + }); + + afterEach(() => { + qc.clear(); + vi.restoreAllMocks(); + }); + + it("invalidates the issue table windows when the status changes", async () => { + const { result } = renderHook(() => useUpdateProject(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync({ id: "p1", status: "paused" }); + }); + + expect(qc.getQueryState(tableKey)?.isInvalidated).toBe(true); + }); + + it("leaves the issue table windows alone when the status is untouched", async () => { + const { result } = renderHook(() => useUpdateProject(), { + wrapper: createWrapper(qc), + }); + + await act(async () => { + await result.current.mutateAsync({ id: "p1", title: "Renamed" }); + }); + + expect(qc.getQueryState(tableKey)?.isInvalidated).toBe(false); + }); }); diff --git a/packages/core/projects/mutations.ts b/packages/core/projects/mutations.ts index 90cf10178d7..86c879f088c 100644 --- a/packages/core/projects/mutations.ts +++ b/packages/core/projects/mutations.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api"; import { projectKeys } from "./queries"; +import { issueKeys } from "../issues/queries"; import { useWorkspaceId } from "../hooks"; import { useRecentContextStore } from "../chat/recent-context-store"; import { clearIssueSurfaceViewState } from "../issues/stores/surface-view-store"; @@ -50,6 +51,13 @@ export function useUpdateProject() { onSettled: (_data, _err, vars) => { qc.invalidateQueries({ queryKey: projectKeys.detail(wsId, vars.id) }); qc.invalidateQueries({ queryKey: projectKeys.list(wsId) }); + // A project's status is a filter dimension of the issue table, so + // changing it moves issues in and out of a filtered window. Nothing + // in the issue payload changes, so only this invalidation can + // refresh it — the global staleTime is Infinity. + if ("status" in vars) { + qc.invalidateQueries({ queryKey: issueKeys.tableAll(wsId) }); + } }, }); } @@ -77,6 +85,11 @@ export function useDeleteProject() { }, onSettled: () => { qc.invalidateQueries({ queryKey: projectKeys.list(wsId) }); + // Deleting a project removes its status from the workspace, so a table + // window filtered on that status still holds its issues. The realtime + // event invalidates too, but a delivery gap must not leave the window + // wrong forever — the global staleTime is Infinity. + qc.invalidateQueries({ queryKey: issueKeys.tableAll(wsId) }); }, }); } diff --git a/packages/core/realtime/use-realtime-sync-task-messages.test.tsx b/packages/core/realtime/use-realtime-sync-task-messages.test.tsx index da95f28a672..c1462d197f6 100644 --- a/packages/core/realtime/use-realtime-sync-task-messages.test.tsx +++ b/packages/core/realtime/use-realtime-sync-task-messages.test.tsx @@ -126,24 +126,23 @@ describe("useRealtimeSync — task:message fanout guards (MUL-6396)", () => { // Mounting registers the cache entry immediately; the queryFn above has // not resolved yet. A frame landing in that window must still be kept. handler(msg(HELD_TASK, 1)); - vi.advanceTimersByTime(FLUSH_MS); expect(cached(qc, HELD_TASK)?.map((m) => m.seq)).toEqual([1]); release(); }); - it("coalesces a burst into a single cache write", () => { + it("writes a burst's first frame immediately and coalesces its tail", () => { const handler = mount(); const release = holdTimeline(HELD_TASK); const writes = vi.spyOn(qc, "setQueryData"); for (let seq = 1; seq <= 5; seq++) handler(msg(HELD_TASK, seq)); - // Nothing is written until the window closes. - expect(writes).not.toHaveBeenCalled(); + expect(writes).toHaveBeenCalledTimes(1); + expect(cached(qc, HELD_TASK)?.map((m) => m.seq)).toEqual([1]); vi.advanceTimersByTime(FLUSH_MS); - expect(writes).toHaveBeenCalledTimes(1); + expect(writes).toHaveBeenCalledTimes(2); expect(cached(qc, HELD_TASK)?.map((m) => m.seq)).toEqual([1, 2, 3, 4, 5]); release(); }); @@ -177,9 +176,8 @@ describe("useRealtimeSync — task:message fanout guards (MUL-6396)", () => { const release = holdTimeline(HELD_TASK); await vi.waitFor(() => expect(listTaskMessages).toHaveBeenCalled()); - // Live frame arrives and flushes while the request is still open. + // The leading-edge live frame lands while the request is still open. handler(msg(HELD_TASK, 2, { content: "live" })); - vi.advanceTimersByTime(FLUSH_MS); expect(cached(qc, HELD_TASK)?.map((m) => m.seq)).toEqual([2]); // The response was snapshotted before seq 2 was persisted. @@ -206,8 +204,10 @@ describe("useRealtimeSync — task:message fanout guards (MUL-6396)", () => { const release = holdTimeline(HELD_TASK); await vi.waitFor(() => expect(cached(qc, HELD_TASK)?.map((m) => m.seq)).toEqual([1])); - // Frame batched while the entry is still held, then the viewer closes. + // The leading edge writes immediately. The second frame is batched while + // the entry is still held, then the viewer closes. handler(msg(HELD_TASK, 2, { content: "live" })); + handler(msg(HELD_TASK, 3, { content: "batched tail" })); release(); // GC lands first (50ms), flush second (100ms). diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 7df45f638b1..937fed3c0c8 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -33,6 +33,7 @@ import { telegramKeys } from "../telegram/queries"; import { onIssueCreated, onIssueUpdated, + onIssueDuplicateMarkChanged, onIssueDeleted, onIssueLabelsChanged, onIssuePropertiesChanged, @@ -125,11 +126,10 @@ const chatWsLogger = createLogger("chat.ws"); * Window over which incoming `task:message` frames are batched into a single * timeline cache write (MUL-6396). * - * A fixed window, armed on the first frame and not reset by later ones, so a - * sustained stream still lands every 100ms rather than being deferred until - * the stream pauses. Short enough that streamed text still reads as live; - * long enough that a run emitting several frames per second costs one merge - * and one render instead of one per frame. + * The first frame after an idle window lands immediately; that is the + * user-visible leading edge. It also arms a fixed 100ms window for subsequent + * frames, not reset by later ones, so a sustained stream still costs at most + * one additional merge/render per window instead of one per frame. */ const TASK_MESSAGE_FLUSH_MS = 100; @@ -793,7 +793,15 @@ export function useRealtimeSync( }, project: () => { const wsId = getCurrentWsId(); - if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) }); + if (wsId) { + qc.invalidateQueries({ queryKey: projectKeys.all(wsId) }); + // The issue table can filter on a project's status, so a + // project create/update/delete changes which issues a filtered + // window holds. The payload carries no previous status to compare + // against, and project writes are rare, so refresh the table + // queries unconditionally rather than guess. + qc.invalidateQueries({ queryKey: issueKeys.tableAll(wsId) }); + } }, squad: () => { const wsId = getCurrentWsId(); @@ -1018,6 +1026,13 @@ export function useRealtimeSync( statusChanged: payload.status_changed, projectChanged: payload.project_changed, }); + onIssueDuplicateMarkChanged( + qc, + wsId, + issue.id, + payload.duplicate_of_issue_id, + payload.prev_duplicate_of_issue_id, + ); if (issue.status) { onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status); } @@ -1380,27 +1395,29 @@ export function useRealtimeSync( const taskMessageBatches = new Map(); let taskMessageFlushTimer: ReturnType | null = null; + const writeTaskMessageBatch = (taskId: string, batch: TaskMessagePayload[]) => { + // Re-check, because a queued batch may be up to one window old and + // `setQueryData` does NOT postpone garbage collection — query-core arms + // that timer when the last observer leaves and never again on write. + // Closing a transcript while its run keeps streaming therefore has the + // entry disappear mid-window, and writing then REBUILDS it holding only + // this batch. With the app-wide `staleTime: Infinity` the next open + // would read that stub as fresh and never fetch, so everything before it + // would be missing until the window is reloaded. Dropping the batch + // instead costs nothing: the rows are persisted, so the next open fetches + // the whole timeline. + if (!isTaskMessageTimelineHeld(qc, taskId)) return; + qc.setQueryData( + chatKeys.taskMessages(taskId), + (old = []) => mergeTaskMessagesBySeq(old, batch), + ); + }; + const flushTaskMessages = () => { taskMessageFlushTimer = null; for (const [taskId, batch] of taskMessageBatches) { - // Re-check, because holding was last verified up to a window ago and - // `setQueryData` does NOT postpone garbage collection — query-core arms - // that timer when the last observer leaves and never again on write. - // Closing a transcript while its run keeps streaming therefore has the - // entry disappear mid-window, and writing then REBUILDS it holding only - // this batch. With the app-wide `staleTime: Infinity` the next open - // would read that stub as fresh and never fetch, so everything before - // it would be missing until the window is reloaded. Dropping the batch - // instead costs nothing: the rows are persisted, so the next open - // fetches the whole timeline. - if (!isTaskMessageTimelineHeld(qc, taskId)) { - continue; - } - qc.setQueryData( - chatKeys.taskMessages(taskId), - (old = []) => mergeTaskMessagesBySeq(old, batch), - ); + writeTaskMessageBatch(taskId, batch); } taskMessageBatches.clear(); }; @@ -1411,14 +1428,17 @@ export function useRealtimeSync( // hot path for every run in the workspace, not just the visible ones. if (!isTaskMessageTimelineHeld(qc, payload.task_id)) return; - const batch = taskMessageBatches.get(payload.task_id); - if (batch) batch.push(payload); - else taskMessageBatches.set(payload.task_id, [payload]); - - // Fixed window, not a resetting debounce: a continuous stream must still - // flush every TASK_MESSAGE_FLUSH_MS instead of being starved until a gap. + // Leading edge: render the first frame after an idle window now. The + // timer is still armed so the remainder of a burst is coalesced and a + // continuous stream cannot render more than once per fixed window after + // this one immediate write. if (!taskMessageFlushTimer) { + writeTaskMessageBatch(payload.task_id, [payload]); taskMessageFlushTimer = setTimeout(flushTaskMessages, TASK_MESSAGE_FLUSH_MS); + } else { + const batch = taskMessageBatches.get(payload.task_id); + if (batch) batch.push(payload); + else taskMessageBatches.set(payload.task_id, [payload]); } chatWsLogger.debug("task:message (global)", { diff --git a/packages/core/types/activity.ts b/packages/core/types/activity.ts index 7a469e4b01f..b17bc0f8577 100644 --- a/packages/core/types/activity.ts +++ b/packages/core/types/activity.ts @@ -33,6 +33,11 @@ export interface TimelineEntry { resolved_by_type?: CommentAuthorType | null; resolved_by_id?: string | null; source_task_id?: string | null; + /** Delivery receipt for a member message explicitly bound to one live run. */ + supplement_task_id?: string; + supplement_status?: "pending" | "delivering" | "delivered" | "failed"; + supplement_failure_reason?: string; + supplement_delivered_at?: string; /** * Set only on a comment deleted while it still had replies: the server keeps * it as an empty tombstone so the replies keep their parent. Read it through diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index 8828c27cf73..78c5a3f30be 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -138,6 +138,12 @@ export const RUNTIME_PROFILE_PROTOCOL_FAMILIES = [ export type RuntimeProtocolFamily = (typeof RUNTIME_PROFILE_PROTOCOL_FAMILIES)[number]; +export const RUNTIME_PROFILE_RUNTIME_TYPES = [ + ...RUNTIME_PROFILE_PROTOCOL_FAMILIES, + "omp", +] as const; +export type RuntimeProfileType = (typeof RUNTIME_PROFILE_RUNTIME_TYPES)[number]; + // Profile visibility mirrors RuntimeVisibility's vocabulary but uses the // workspace/private axis the server documents for profiles. export type RuntimeProfileVisibility = "workspace" | "private"; @@ -147,6 +153,7 @@ export interface RuntimeProfile { workspace_id: string; display_name: string; protocol_family: RuntimeProtocolFamily; + runtime_type?: RuntimeProfileType; command_name: string; description: string | null; fixed_args: string[]; @@ -157,12 +164,14 @@ export interface RuntimeProfile { updated_at: string; } -// POST body. `protocol_family` is required and immutable after creation. +// POST body. runtime_type is the immutable compatibility target; the server +// derives protocol_family. Older clients may still send protocol_family alone. // Optional fields are omitted entirely when unset (never sent as null/empty) // so the server applies its own defaults. export interface CreateRuntimeProfileRequest { display_name: string; - protocol_family: RuntimeProtocolFamily; + protocol_family?: RuntimeProtocolFamily; + runtime_type?: RuntimeProfileType; command_name: string; description?: string; fixed_args?: string[]; @@ -289,6 +298,7 @@ export interface TaskCancellationActor { } export interface AgentTask { + wakeup_id?: string; id: string; agent_id: string; runtime_id: string; @@ -305,6 +315,7 @@ export interface AgentTask { | "queued" | "dispatched" | "waiting_local_directory" + | "deferred" | "running" | "completed" | "failed" @@ -355,6 +366,12 @@ export interface AgentTask { * trigger/coalesced union; an explicitly empty array is still authoritative. */ delivered_comment_ids?: string[]; + /** Exact run-scoped live-input capability negotiated at this task's start. */ + supplement_capability?: string; + /** Ordinary historical comments explicitly bound to this run, in send order. */ + supplement_comment_ids?: string[]; + /** Server-side invocation verdict for the current member and this agent. */ + can_supplement?: boolean; /** * Canonical short description of what triggered this task — snapshot * taken at creation time. For comment-triggered tasks it's the diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 6bfb2f00014..e1fbf7cfee0 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -1,7 +1,7 @@ import type { Issue, IssueMetadata, IssueStatus, IssueStatusCategory, IssuePriority, IssueAssigneeType } from "./issue"; -import type { PropertyFilterValue } from "./property"; +import type { IssuePropertyValues, PropertyFilterValue } from "./property"; import type { MemberRole } from "./workspace"; -import type { Project } from "./project"; +import type { Project, ProjectStatus } from "./project"; // Issue API export interface CreateIssueRequest { @@ -21,6 +21,9 @@ export interface CreateIssueRequest { /** Issue-scoped label IDs to attach in the same transaction as the create. * Unknown or non-issue ids are rejected by the server with 400. */ label_ids?: string[]; + /** ID-keyed custom-property values validated and persisted atomically with + * the issue. */ + properties?: IssuePropertyValues; } export interface CreateCommentSubIssueManualRequest { @@ -77,6 +80,19 @@ export interface UpdateIssueRequest { * MUL-3375). The assignee/status change still applies. Control field — * strip from optimistic cache patches; never written onto the Issue. */ suppress_run?: boolean; + /** Marks this issue as a duplicate of another issue (MUL-7349). The server + * also sets status to cancelled; any later status change away from + * cancelled removes the mark. Write-only — read it back through + * `listIssueDuplicates`. Control field: strip from optimistic patches. */ + duplicate_of_issue_id?: string; +} + +/** Both sides of an issue's duplicate relation (MUL-7349). */ +export interface IssueDuplicates { + /** The original this issue duplicates, when it is marked as a duplicate. */ + duplicate_of: Issue | null; + /** Issues marked as duplicates of this one. */ + duplicates: Issue[]; } /** @@ -293,6 +309,10 @@ export interface IssueTableFilters { creators?: IssueActorRef[]; project_ids?: string[]; include_no_project?: boolean; + /** Lifecycle status of the parent project. A separate dimension from + * `project_ids` (AND across the two); an issue with no project never + * matches. */ + project_statuses?: ProjectStatus[]; label_ids?: string[]; /** Same shape as `ListIssuesParams.properties`: bare strings are exact * equality / "No value", operator objects narrow scalar matches. */ diff --git a/packages/core/types/comment.ts b/packages/core/types/comment.ts index b0bcf5a8f61..aaca94eeb8e 100644 --- a/packages/core/types/comment.ts +++ b/packages/core/types/comment.ts @@ -47,6 +47,10 @@ export interface Comment { // Per-target result of every explicit @agent / @squad mention in this comment // (MUL-4525 §2). Present only on create/edit responses; older servers omit it. trigger_outcomes?: CommentTriggerOutcome[]; + supplement_task_id?: string; + supplement_status?: "pending" | "delivering" | "delivered" | "failed"; + supplement_failure_reason?: string; + supplement_delivered_at?: string; } // The domain result of one explicitly-mentioned trigger target. Success-shaped diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index efc79dc1e8c..bc7d791f6a4 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -117,6 +117,10 @@ export interface IssueUpdatedPayload { assignee_changed?: boolean; status_changed?: boolean; project_changed?: boolean; + // Both ends of a duplicate-mark change (MUL-7349). The mark is not on Issue, + // so these tell the realtime layer whose duplicate relations to refresh. + duplicate_of_issue_id?: string | null; + prev_duplicate_of_issue_id?: string | null; } export interface IssueDeletedPayload { @@ -285,6 +289,8 @@ export interface ActivityCreatedPayload { } export interface TaskMessagePayload { + /** Opaque tool-call identity, scoped to one backend execution. */ + call_id?: string; task_id: string; issue_id: string; chat_session_id?: string; diff --git a/packages/core/types/github.ts b/packages/core/types/github.ts index 8d40424e5ab..ea97c96ce98 100644 --- a/packages/core/types/github.ts +++ b/packages/core/types/github.ts @@ -57,6 +57,39 @@ export interface GitHubInstallation { connected_by?: string; } +/** Why a PR is on an issue: linked by hand, or matched in its title / branch. + * "auto" is any other automatic link, such as "Closes MUL-1" in the body. */ +export type PullRequestLinkSource = "manual" | "title" | "branch" | "auto"; + +/** What the "every linked PR merged, one says Closes → Done" rule will do for + * one issue. The server computes it; the issue page only renders it. */ +export type PRAutoCompleteState = + | "none" + | "workspace_disabled" + | "issue_disabled" + | "terminal" + | "triage" + | "no_close_intent" + | "waiting" + | "not_merged" + | "all_merged"; + +export interface PRAutoComplete { + /** Unknown future states are kept as strings; the UI renders nothing for them. */ + state: PRAutoCompleteState | (string & {}); + /** PRs the state is about: still open for `waiting`, closed without merging + * for `not_merged`, every linked PR for `all_merged`. */ + pull_request_ids: string[]; + issue_disabled: boolean; + workspace_enabled: boolean; +} + +export interface IssuePullRequestsResponse { + pull_requests: GitHubPullRequest[]; + /** Absent on older backends. */ + auto_complete: PRAutoComplete | null; +} + export interface GitHubPullRequest { id: string; /** Source provider. Older GitHub-only backends omit it. */ @@ -75,6 +108,8 @@ export interface GitHubPullRequest { closed_at: string | null; pr_created_at: string; pr_updated_at: string; + /** Only set on an issue's PR list. Older backends omit it. */ + link_source?: PullRequestLinkSource; /** Conflict verdict from the GitHub API snapshot. Answers ONLY * "is there a conflict"; older backends omit it. */ mergeable?: GitHubPullRequestMergeable | null; diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index f070e2284f9..c80945219af 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -1,4 +1,6 @@ -export type { Issue, IssueStatus, BuiltInIssueStatus, IssuePriority, IssueAssigneeType, IssueMetadata, IssueMetadataValue, IssueReaction, SourceContextAttachment, SourceContextAuthor, SourceContextIssueSnapshot, SourceContextCommentSnapshot, SourceContextSnapshot, SourceContextLimitUsage, SourceContextPreview, SourceContextAuthorState, IssueSourceContext } from "./issue"; +export type { Issue, IssueStatus, BuiltInIssueStatus, IssuePriority, IssueAssigneeType, IssueMetadata, IssueMetadataValue, IssueReaction, SourceContextAttachment, SourceContextAuthor, SourceContextIssueSnapshot, SourceContextCommentSnapshot, SourceContextSnapshot, SourceContextLimitUsage, SourceContextPreview, SourceContextAuthorState, IssueSourceContext, + IssueDuplicateOf, +} from "./issue"; export type { IssueStatusCategory, IssueStatusEntry, @@ -32,6 +34,7 @@ export type { RuntimeDevice, RuntimeProfile, RuntimeProtocolFamily, + RuntimeProfileType, RuntimeProfileVisibility, CreateRuntimeProfileRequest, UpdateRuntimeProfileRequest, @@ -87,7 +90,7 @@ export type { IssueUsageSummary, MikaBootstrapResponse, } from "./agent"; -export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent"; +export { RUNTIME_PROFILE_PROTOCOL_FAMILIES, RUNTIME_PROFILE_RUNTIME_TYPES } from "./agent"; export type { Workspace, WorkspaceRepo, WorkspaceMcpServer, Member, MemberRole, User, MemberWithUser, Invitation, ShareLink, ShareLinkInfo } from "./workspace"; export type { PluginInstallation, @@ -197,6 +200,10 @@ export type { GitHubPullRequestMergeable, GitHubPullRequestMergeStateStatus, GitHubPullRequestState, + IssuePullRequestsResponse, + PRAutoComplete, + PRAutoCompleteState, + PullRequestLinkSource, ListGitHubInstallationsResponse, GitHubRepository, ListGitHubRepositoriesResponse, @@ -332,3 +339,6 @@ export type { WorkspaceSubscriptionSeatReconcileResult, CreateWorkspaceSubscriptionPortalResponse, } from "./billing"; +export type { IssueWakeup, WakeupPreview, IssueWakeupSummaryRow } from "./issue-wakeup"; + +export type { WorkspaceWakeup, WorkspaceWakeupPage, WorkspaceWakeupFilters, WakeupScope } from "./issue-wakeup"; diff --git a/packages/core/types/issue-wakeup.ts b/packages/core/types/issue-wakeup.ts new file mode 100644 index 00000000000..af886d5aeeb --- /dev/null +++ b/packages/core/types/issue-wakeup.ts @@ -0,0 +1,74 @@ +export interface IssueWakeup { + id: string; + issue_id: string; + agent_id: string; + agent_name: string; + instruction: string; + kind: "event" | "at" | "every" | "cron"; + mode: "once" | "continuous"; + event_types: string[]; + filter_agent_id: string | null; + filter_task_id: string | null; + filter_actor_type?: "member" | "agent" | null; + filter_actor_id?: string | null; + filter_actor_name?: string | null; + interval_seconds: number | null; + cron_expression: string | null; + timezone: string; + next_fire_at: string | null; + enabled: boolean; + revision?: number; + disabled_at: string | null; + last_task_id: string | null; + last_error: string | null; + filter_agent_name?: string | null; + last_task_status?: string | null; +} + +export type WakeupPreview = Pick< + IssueWakeup, + | "id" + | "issue_id" + | "agent_id" + | "agent_name" + | "kind" + | "mode" + | "event_types" + | "filter_task_id" + | "filter_agent_name" + | "filter_actor_type" + | "filter_actor_id" + | "filter_actor_name" + | "interval_seconds" + | "cron_expression" + | "timezone" + | "next_fire_at" +>; +export interface IssueWakeupSummaryRow extends WakeupPreview { + active_count: number; + event_count: number; +} + +export type WakeupScope = "active" | "all" | "disabled" | "ended"; +export interface WorkspaceWakeup extends Omit { + issue_title: string; + issue_identifier: string; + issue_closed: boolean; + can_manage: boolean; + active_runs: number; + task: import("./agent").AgentTask | null; +} +export interface WorkspaceWakeupPage { + items: WorkspaceWakeup[]; + total: number; + counts: Record; + agents: { id: string; name: string }[]; +} +export interface WorkspaceWakeupFilters { + scope: WakeupScope; + kind: "all" | "event" | "at" | "recurring"; + search: string; + agent_id: string; + offset: number; + limit: number; +} diff --git a/packages/core/types/issue.ts b/packages/core/types/issue.ts index 964d1366769..d04c4fc0167 100644 --- a/packages/core/types/issue.ts +++ b/packages/core/types/issue.ts @@ -159,6 +159,14 @@ export interface IssueSourceContext { snapshot: SourceContextSnapshot; } +/** The original a duplicate points at: enough to link it and show its status. */ +export interface IssueDuplicateOf { + id: string; + identifier: string; + title: string; + status: IssueStatus; +} + export interface Issue { id: string; workspace_id: string; @@ -188,6 +196,12 @@ export interface Issue { creator_type: IssueAssigneeType; creator_id: string; parent_issue_id: string | null; + /** + * The original this issue duplicates (MUL-7349): present only while the + * issue is cancelled and the original still exists, resolved by the server. + * Absent when connected to an older backend. + */ + duplicate_of?: IssueDuplicateOf | null; project_id: string | null; position: number; // Ordered barrier group among sibling sub-issues (null = unstaged). The diff --git a/packages/ui/components/common/border-beam.tsx b/packages/ui/components/common/border-beam.tsx new file mode 100644 index 00000000000..70f77573729 --- /dev/null +++ b/packages/ui/components/common/border-beam.tsx @@ -0,0 +1,12 @@ +/** + * Decorative highlight that sweeps around the host's rounded border. Render it + * as a child of a positioned (`relative`), rounded host; the ring inherits the + * host's border radius. + */ +export function BorderBeam() { + return ( +
- {!showWebhookUrlRow && deleteButton} + {!showWebhookUrlRow && ( +
+ {editButton} + {deleteButton} +
+ )} { if (!v && !deleting) setConfirmOpen(false); }}> @@ -433,6 +461,12 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig + { if (!v && !rotateToken.isPending) setRotateOpen(false); }}> diff --git a/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx b/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx index 8f3acfa37be..432c7bbadaf 100644 --- a/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx +++ b/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx @@ -262,3 +262,66 @@ describe("AutopilotDialog schedule section on an autopilot that has one", () => expect(mockCreateTrigger).not.toHaveBeenCalled(); }); }); + +// Regression cover for MUL-7478: the schedule panel counted triggers of every +// kind, so a 1 schedule + 1 webhook autopilot — where exactly one row can take +// a cron and the write below names it — was locked out of schedule editing +// entirely, with a notice pointing at a detail page that had no editor either. +describe("AutopilotDialog schedule section on an autopilot with several triggers", () => { + beforeEach(() => { + mockUpdateAutopilot.mockReset().mockResolvedValue({ id: AUTOPILOT_ID }); + mockCreateTrigger.mockReset().mockResolvedValue({ id: "trg-new" }); + mockUpdateTrigger.mockReset().mockResolvedValue({ id: "trg-sched" }); + }); + + it("edits the one schedule of an autopilot that also has a webhook", async () => { + const user = userEvent.setup(); + renderEditDialog([ + trigger({ id: "trg-sched" }), + trigger({ id: "trg-hook", kind: "webhook", cron_expression: null, timezone: null }), + ]); + + // The stored schedule, live — not the locked notice a second trigger of any + // kind used to produce. + expect(screen.getByTestId("timezone-picker")).toHaveTextContent("Asia/Shanghai"); + expect(screen.queryByText(/Close this dialog/)).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "At an interval" })); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + // The schedule row, never the webhook one: the API rejects a cron on any + // other kind, and rotating the webhook's URL out from under its callers + // would be the wrong write to guess at. + expect(mockUpdateTrigger.mock.calls[0]?.[0]).toMatchObject({ + autopilotId: AUTOPILOT_ID, + triggerId: "trg-sched", + }); + expect(mockCreateTrigger).not.toHaveBeenCalled(); + }); + + it("states which schedules to go and edit instead of showing the first of them", async () => { + const user = userEvent.setup(); + renderEditDialog([ + trigger({ id: "trg-morning" }), + trigger({ id: "trg-evening", cron_expression: "TZ=Asia/Shanghai 0 18 * * *" }), + ]); + + expect( + screen.getByText( + "This autopilot has 2 schedules. Close this dialog and edit each one under Triggers below.", + ), + ).toBeInTheDocument(); + // A single-value editor cannot show two schedules, and a disabled one + // showing the first is still the half-truth a reader would set a clock by. + expect(screen.queryByTestId("timezone-picker")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "At an interval" })).not.toBeInTheDocument(); + + await user.click(saveButton()); + + // Other fields still save; the schedules are left to the trigger rows. + await waitFor(() => expect(mockUpdateAutopilot).toHaveBeenCalledTimes(1)); + expect(mockUpdateTrigger).not.toHaveBeenCalled(); + expect(mockCreateTrigger).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/views/autopilots/components/autopilot-dialog.tsx b/packages/views/autopilots/components/autopilot-dialog.tsx index c6ba1fbc4b2..1c761da6a43 100644 --- a/packages/views/autopilots/components/autopilot-dialog.tsx +++ b/packages/views/autopilots/components/autopilot-dialog.tsx @@ -227,14 +227,22 @@ export function AutopilotDialog(props: AutopilotDialogProps) { // above. Null means there is none to patch, so the write creates one. const scheduleTriggerIdRef = useRef(existingSchedule?.id ?? null); - const triggerCount = isCreate ? 0 : props.triggers.length; - const schedulePillDisabled = !isCreate && triggerCount >= 2; + // Only SCHEDULE rows can make this panel ambiguous, so only they are counted. + // Counting every kind locked a 1 schedule + 1 webhook autopilot (MUL-7478), + // where `existingSchedule` above names exactly one row and the write below + // has nowhere else to land. Two schedules is the real ambiguity: this editor + // holds one `ScheduleConfig`, so it would show the first row as if it were + // the whole story and save would silently rewrite that one alone. + const scheduleTriggerCount = isCreate + ? 0 + : props.triggers.filter((trig) => trig.kind === "schedule").length; + const schedulePillDisabled = !isCreate && scheduleTriggerCount >= 2; // The manual-autopilot empty state, and the only path to a first schedule - // from this dialog. Skipped when the panel is locked (2+ triggers), which - // keeps that case rendering exactly the disabled editor it always has. + // from this dialog. A locked panel never reaches it: locking means two + // schedules exist, so `existingSchedule` is non-null whenever it is true. const showScheduleEmptyState = - !isCreate && existingSchedule === null && !scheduleAdded && !schedulePillDisabled; + !isCreate && existingSchedule === null && !scheduleAdded; const selectedAssignee = useMemo(() => { if (!assigneeId) return null; @@ -649,7 +657,9 @@ export function AutopilotDialog(props: AutopilotDialogProps) { {triggerKind === "schedule" ? (
{t(($) => $.dialog.section_schedule)} - {showScheduleEmptyState ? ( + {schedulePillDisabled ? ( + + ) : showScheduleEmptyState ? ( setScheduleAdded(true)} /> ) : ( /* No `onValidityChange` / `clearRejection` here, unlike the @@ -666,12 +676,7 @@ export function AutopilotDialog(props: AutopilotDialogProps) { // over the network and then writes the schedule it read before // that round trip, so an edit made in between would be dropped // on the floor with a success toast over it. - disabled={schedulePillDisabled || submitting} - disabledReason={ - schedulePillDisabled - ? t(($) => $.dialog.schedule_disabled_reason) - : undefined - } + disabled={submitting} /> )}
@@ -964,6 +969,22 @@ function SubscribersSection({ } +// The panel cannot speak for two schedules, and a disabled editor showing the +// first of them is still half a truth — the one the reader would set their +// clock by. It says what it cannot do and where the reader can: this dialog +// only ever opens from the detail page, so the Triggers list is already on +// screen behind it. +function ScheduleMultipleNotice({ count }: { count: number }) { + const { t } = useT("autopilots"); + return ( +
+

+ {t(($) => $.dialog.schedule_multiple_notice, { count })} +

+
+ ); +} + // The schedule section of an autopilot that has none. Mirrors the detail // page's trigger empty state — a dashed card that states the autopilot is // manual — so the two surfaces agree on what "no schedule" looks like instead diff --git a/packages/views/autopilots/components/autopilots-page.test.tsx b/packages/views/autopilots/components/autopilots-page.test.tsx new file mode 100644 index 00000000000..f1187b18281 --- /dev/null +++ b/packages/views/autopilots/components/autopilots-page.test.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { expect, it, vi } from "vitest"; +import { fireEvent, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { api } from "@multica/core/api"; +import { NavigationProvider } from "../../navigation"; +import { renderWithI18n } from "../../test/i18n"; +import { AutopilotsPage } from "./autopilots-page"; + +vi.mock("@multica/core/api", () => ({ api: { listAutopilots: vi.fn() } })); +vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws" })); +vi.mock("@multica/core/paths", () => ({ + useWorkspacePaths: () => ({ autopilots: () => "/ws/autopilots" }), +})); +vi.mock("./workspace-wakeups", () => ({ + WorkspaceWakeups: () =>
Wakeup inventory
, +})); +vi.mock("./autopilot-dialog", () => ({ AutopilotDialog: () => null })); + +function Harness() { + const [path, setPath] = useState("/ws/autopilots?keep=yes"); + const url = new URL(path, "https://example.test"); + return ( + {}, + getShareableUrl: (v) => v, + }} + > + + + ); +} +it.each(["empty", "error"])( + "keeps wakeups reachable when autopilots are %s", + async (state) => { + if (state === "empty") + vi.mocked(api.listAutopilots).mockResolvedValue({ autopilots: [], total: 0 }); + else + vi.mocked(api.listAutopilots).mockRejectedValue( + new Error("Autopilots unavailable"), + ); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + renderWithI18n( + + + , + ); + await screen.findByText( + state === "empty" ? "No autopilots yet" : "Autopilots unavailable", + ); + fireEvent.click(screen.getByRole("tab", { name: "Issue wakeups" })); + expect(await screen.findByText("Wakeup inventory")).toBeVisible(); + expect(screen.queryByRole("button", { name: "New autopilot" })).toBeNull(); + fireEvent.click( + screen.getByRole("tab", { name: "Autopilot" }), + ); + expect( + await screen.findByRole("button", { name: "New autopilot" }), + ).toBeVisible(); + }, +); diff --git a/packages/views/autopilots/components/autopilots-page.tsx b/packages/views/autopilots/components/autopilots-page.tsx index 8e587e3042a..a773dadb12a 100644 --- a/packages/views/autopilots/components/autopilots-page.tsx +++ b/packages/views/autopilots/components/autopilots-page.tsx @@ -44,7 +44,14 @@ import { type ListGridSortDirection, } from "@multica/ui/components/ui/list-grid"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; -import { useRowLink } from "../../navigation"; +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "@multica/ui/components/ui/tabs"; +import { WorkspaceWakeups } from "./workspace-wakeups"; +import { useNavigation, useRowLink } from "../../navigation"; import { ActorAvatar } from "../../common/actor-avatar"; import { formatInTimeZone } from "../../common/format-in-time-zone"; import { @@ -53,7 +60,10 @@ import { CollectionPageState, } from "../../layout/collection-page"; import { AutopilotDialog } from "./autopilot-dialog"; -import { AutopilotListToolbar, actorFilterValue } from "./autopilot-list-toolbar"; +import { + AutopilotListToolbar, + actorFilterValue, +} from "./autopilot-list-toolbar"; import { AutopilotBatchToolbar, AutopilotRowActions, @@ -139,7 +149,10 @@ interface AutopilotTemplate { schedule: Pick; } -const WEEKDAYS: ScheduleConfig["days"] = { kind: "weekly", daysOfWeek: [1, 2, 3, 4, 5] }; +const WEEKDAYS: ScheduleConfig["days"] = { + kind: "weekly", + daysOfWeek: [1, 2, 3, 4, 5], +}; const MONDAY: ScheduleConfig["days"] = { kind: "weekly", daysOfWeek: [1] }; const TEMPLATES: AutopilotTemplate[] = [ @@ -227,7 +240,9 @@ function CheckboxCell({ onToggle(); }} className={`-m-1.5 flex items-center p-1.5 ${ - checked ? "" : "opacity-0 transition-opacity group-hover/row:opacity-100" + checked + ? "" + : "opacity-0 transition-opacity group-hover/row:opacity-100" }`} > $.run_status[knownStatus]) : status ?? undefined} + title={ + knownStatus + ? t(($) => $.run_status[knownStatus]) + : (status ?? undefined) + } className={`size-1.5 shrink-0 rounded-full ${runStatusDotClass(status)}`} /> @@ -397,7 +416,9 @@ function ModeCell({ autopilot }: { autopilot: Autopilot }) { : mode; return ( - {label} + + {label} + ); } @@ -599,6 +620,9 @@ function LoadingSkeleton() { // --------------------------------------------------------------------------- export function AutopilotsPage() { + const navigation = useNavigation(); + const tab = + navigation.searchParams.get("tab") === "wakeups" ? "wakeups" : "autopilots"; const { t } = useT("autopilots"); const locale = useLocale(); const wsId = useWorkspaceId(); @@ -609,7 +633,10 @@ export function AutopilotsPage() { isLoading, error: listError, refetch: refetchList, - } = useQuery(autopilotListOptions(wsId)); + } = useQuery({ + ...autopilotListOptions(wsId), + enabled: !!wsId && tab === "autopilots", + }); const [createOpen, setCreateOpen] = useState(false); const [selectedTemplate, setSelectedTemplate] = @@ -680,7 +707,10 @@ export function AutopilotsPage() { ) { return false; } - if (filters.modes.length > 0 && !filters.modes.includes(a.execution_mode)) { + if ( + filters.modes.length > 0 && + !filters.modes.includes(a.execution_mode) + ) { return false; } if ( @@ -752,9 +782,7 @@ export function AutopilotsPage() { const lastVirtual = virtualItems[virtualItems.length - 1]; const virtualPadding = { top: firstVirtual ? firstVirtual.start : 0, - bottom: lastVirtual - ? rowVirtualizer.getTotalSize() - lastVirtual.end - : 0, + bottom: lastVirtual ? rowVirtualizer.getTotalSize() - lastVirtual.end : 0, }; const totalCount = autopilots.length; @@ -763,222 +791,259 @@ export function AutopilotsPage() { return ( // relative: positioning anchor for the batch toolbar (page-centered, // not viewport-centered). -
+ { + const params = new URLSearchParams(navigation.searchParams); + if (value === "wakeups") params.set("tab", "wakeups"); + else params.delete("tab"); + navigation.replace( + `${navigation.pathname}${params.size ? `?${params}` : ""}${navigation.hash}`, + ); + }} + > {/* Header */} $.page.title)} - count={totalCount} + count={tab === "autopilots" ? totalCount : undefined} actions={ - $.page.new_autopilot)} - onClick={() => openCreate()} - /> + tab === "autopilots" && ( + $.page.new_autopilot)} + onClick={() => openCreate()} + /> + ) } /> - {listError ? ( - + $.page.title)}> + {t(($) => $.page.title)} + {t(($) => $.wakeups.title)} + +
+ + + + + {listError ? ( + refetchList()} + > + {t(($) => $.page.retry)} + + } + /> + ) : isLoading ? ( +
+ +
+ ) : showEmpty ? ( +
+ +

+ {t(($) => $.page.empty.title)} +

+

+ {t(($) => $.page.empty.hint)} +

+
+ {TEMPLATES.map((tpl) => { + const Icon = tpl.icon; + return ( + + ); + })} +
- } - /> - ) : isLoading ? ( -
- -
- ) : showEmpty ? ( -
- -

- {t(($) => $.page.empty.title)} -

-

- {t(($) => $.page.empty.hint)} -

-
- {TEMPLATES.map((tpl) => { - const Icon = tpl.icon; - return ( - - ); - })}
- -
- ) : ( - <> - -
- + +
- - - {rows.length === 0 && ( -
- {t(($) => $.page.no_matches)} -
- )} - {virtualItems.map((vi) => { - const autopilot = rows[vi.index]; - if (!autopilot) return null; - return ( - - toggleSelected(autopilot.id)} - /> - - {isColVisible("assignee") ? ( - - ) : ( - - )} - {isColVisible("trigger") ? ( - - ) : ( - - )} - {isColVisible("lastRun") ? ( - - ) : ( - - )} - {isColVisible("nextRun") ? ( - - ) : ( - - )} - {isColVisible("mode") ? ( - - ) : ( - - )} - {isColVisible("creator") ? ( - - ) : ( - - )} - {isColVisible("created") ? ( - - {new Date(autopilot.created_at).toLocaleDateString(locale)} + + + {rows.length === 0 && ( +
+ {t(($) => $.page.no_matches)} +
+ )} + {virtualItems.map((vi) => { + const autopilot = rows[vi.index]; + if (!autopilot) return null; + return ( + + toggleSelected(autopilot.id)} + /> + + {isColVisible("assignee") ? ( + + ) : ( + + )} + {isColVisible("trigger") ? ( + + ) : ( + + )} + {isColVisible("lastRun") ? ( + + ) : ( + + )} + {isColVisible("nextRun") ? ( + + ) : ( + + )} + {isColVisible("mode") ? ( + + ) : ( + + )} + {isColVisible("creator") ? ( + + ) : ( + + )} + {isColVisible("created") ? ( + + {new Date(autopilot.created_at).toLocaleDateString( + locale, + )} + + ) : ( + + )} + + - ) : ( - - )} - - - - - ); - })} -
- -
- - )} - - setSelectedIds(new Set())} - /> + + ); + })} + +
+
+ + )} - {createOpen && ( - $.templates[selectedTemplate.id].title), - description: selectedTemplate.prompt, - } - : undefined - } - initialSchedule={selectedTemplate ? selectedTemplate.schedule : undefined} + setSelectedIds(new Set())} /> - )} -
+ + {createOpen && ( + $.templates[selectedTemplate.id].title), + description: selectedTemplate.prompt, + } + : undefined + } + initialSchedule={ + selectedTemplate ? selectedTemplate.schedule : undefined + } + /> + )} +
+ ); } diff --git a/packages/views/autopilots/components/edit-schedule-trigger-dialog.test.tsx b/packages/views/autopilots/components/edit-schedule-trigger-dialog.test.tsx new file mode 100644 index 00000000000..352cead4324 --- /dev/null +++ b/packages/views/autopilots/components/edit-schedule-trigger-dialog.test.tsx @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { AutopilotTrigger } from "@multica/core/types"; +import { renderWithI18n } from "../../test/i18n"; + +// The editor a trigger row opens (MUL-7478). Before it, an existing schedule +// could only be deleted and recreated: the autopilot dialog's panel speaks for +// one schedule, and the detail page listed triggers read-only. + +const mockUpdateTrigger = vi.hoisted(() => vi.fn()); + +vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test" })); + +// The submit path validates over the network before it writes. Parking that +// round trip holds the dialog mid-flight, in the window a label typed after +// Save used to fall into. +const preview = vi.hoisted(() => ({ release: null as null | (() => void), hold: false })); + +vi.mock("@multica/core/autopilots/queries", () => ({ + cronPreviewOptions: (wsId: string, expr: string, tz: string) => ({ + queryKey: ["cron-preview", wsId, expr, tz], + queryFn: async () => { + if (preview.hold) { + await new Promise((resolve) => { + preview.release = resolve; + }); + } + return { next_runs: ["2126-07-14T01:00:00Z"] }; + }, + retry: false, + }), +})); + +vi.mock("@multica/core/autopilots/mutations", () => ({ + useUpdateAutopilotTrigger: () => ({ mutateAsync: mockUpdateTrigger }), +})); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock("./pickers/timezone-picker", () => ({ + TimezonePicker: ({ value }: { value: string }) =>
{value}
, +})); + +import { EditScheduleTriggerDialog } from "./edit-schedule-trigger-dialog"; + +const AUTOPILOT_ID = "ap-1"; + +function trigger(overrides: Partial = {}): AutopilotTrigger { + return { + id: "trg-evening", + autopilot_id: AUTOPILOT_ID, + kind: "schedule", + enabled: true, + cron_expression: "TZ=Asia/Bangkok 0 */3 * * *", + timezone: "Asia/Bangkok", + next_run_at: null, + webhook_token: null, + label: null, + last_fired_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function renderDialog(trig: AutopilotTrigger = trigger()) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const onOpenChange = vi.fn(); + const tree = (next: AutopilotTrigger) => ( + + + + ); + const result = renderWithI18n(tree(trig)); + // The detail query refreshing under an open dialog: new props, same mount. + return { ...result, onOpenChange, refreshProps: (next: AutopilotTrigger) => result.rerender(tree(next)) }; +} + +const saveButton = () => screen.getByRole("button", { name: "Save" }); +const labelInput = () => screen.getByPlaceholderText("e.g. Weekday morning"); +const enabledSwitch = () => screen.getByRole("switch", { name: "Enabled" }); + +beforeEach(() => { + mockUpdateTrigger.mockReset().mockResolvedValue({ id: "trg-evening" }); + preview.hold = false; + preview.release = null; +}); + +describe("EditScheduleTriggerDialog", () => { + it("opens on the schedule the row already runs, not on a default", () => { + renderDialog(); + + // The stored zone and interval, read back from the row — seeding the editor + // with its own 09:00 default would be a proposal dressed as the trigger's + // state, which is how MUL-5649 lost a save under a success toast. + expect(screen.getByTestId("timezone-picker")).toHaveTextContent("Asia/Bangkok"); + expect(screen.getByRole("button", { name: "At an interval", pressed: true })).toBeInTheDocument(); + expect(screen.getByDisplayValue("3")).toBeInTheDocument(); + }); + + it("patches this trigger alone, carrying the zone with the expression", async () => { + const user = userEvent.setup(); + const { onOpenChange } = renderDialog(); + + await user.click(screen.getByRole("button", { name: "At a time" })); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + const patch = mockUpdateTrigger.mock.calls[0]?.[0]; + expect(patch).toMatchObject({ + autopilotId: AUTOPILOT_ID, + triggerId: "trg-evening", + timezone: "Asia/Bangkok", + }); + expect(patch.cron_expression).toContain("Asia/Bangkok"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("keeps the dialog open when the write fails, with the server's reason", async () => { + const user = userEvent.setup(); + const { onOpenChange } = renderDialog(); + mockUpdateTrigger.mockRejectedValueOnce(new Error("cron_expression is invalid")); + + await user.click(screen.getByRole("button", { name: "At a time" })); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + const { toast } = await import("sonner"); + expect(toast.error).toHaveBeenCalledWith("cron_expression is invalid"); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); +}); + +// The PATCH is partial on purpose. A changed cron / timezone / enabled reads +// server-side as a substantive edit: it republishes the rule version and moves +// this trigger's accountability to whoever saved (`UpdateAutopilotTrigger` in +// server/internal/handler/autopilot.go, MUL-4302). Since `parseCron` → `toCron` +// hands an untouched schedule back normalized — `TZ=` prefix and all, textually +// different from the stored row — resending it would make a rename look like a +// schedule change and carry that responsibility along with it. +describe("EditScheduleTriggerDialog sends only what the user changed", () => { + it("sends the label alone when only the label was touched", async () => { + const user = userEvent.setup(); + // Stored without the prefix the editor adds back, so a resend would be + // visibly a different string to the server. + renderDialog(trigger({ cron_expression: "0 */3 * * *", label: "Old name" })); + + await user.clear(labelInput()); + await user.type(labelInput(), "Evening sweep"); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + expect(mockUpdateTrigger.mock.calls[0]?.[0]).toEqual({ + autopilotId: AUTOPILOT_ID, + triggerId: "trg-evening", + label: "Evening sweep", + }); + }); + + it("pauses a schedule without resending the schedule", async () => { + const user = userEvent.setup(); + renderDialog(trigger({ cron_expression: "0 */3 * * *" })); + + await user.click(enabledSwitch()); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + // The cron stays where it is — pausing is not an edit to it, and the row + // keeps running the same schedule if it is switched back on. + expect(mockUpdateTrigger.mock.calls[0]?.[0]).toEqual({ + autopilotId: AUTOPILOT_ID, + triggerId: "trg-evening", + enabled: false, + }); + }); + + it("has nothing to save until something changes", async () => { + const user = userEvent.setup(); + renderDialog(); + + // A no-op PATCH is not free: the server recomputes and rewrites the row's + // next_run_at from whatever it is sent, so opening and saving a dialog the + // user never edited would still move a reading they never touched. + expect(saveButton()).toBeDisabled(); + + await user.click(enabledSwitch()); + expect(saveButton()).toBeEnabled(); + + await user.click(enabledSwitch()); + expect(saveButton()).toBeDisabled(); + expect(mockUpdateTrigger).not.toHaveBeenCalled(); + }); + + it("takes no label the in-flight write could not carry", async () => { + const user = userEvent.setup(); + renderDialog(); + preview.hold = true; + + await user.click(screen.getByRole("button", { name: "At a time" })); + await user.click(saveButton()); + + // Parked mid-validation: submit has already read the label it will send, so + // the input locks rather than accepting one this write cannot carry and the + // closing dialog would swallow. + await waitFor(() => expect(labelInput()).toBeDisabled()); + expect(mockUpdateTrigger).not.toHaveBeenCalled(); + + preview.release?.(); + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + expect(mockUpdateTrigger.mock.calls[0]?.[0].label).toBeUndefined(); + }); + + it("does not call a stored label with stray whitespace an edit", () => { + renderDialog(trigger({ label: " Morning sweep " })); + + // The baseline is trimmed the way submit trims, so opening the dialog on a + // historical label does not by itself arm Save. + expect(saveButton()).toBeDisabled(); + }); + + it("leaves alone a field a teammate changed under the open dialog", async () => { + const user = userEvent.setup(); + const { refreshProps } = renderDialog(trigger({ label: "Old name" })); + + // A teammate renames this row and pauses it; the detail query refreshes and + // the dialog takes the new props without remounting, so its untouched + // controls still hold what it opened on. + refreshProps(trigger({ label: "Renamed by teammate", enabled: false })); + + // This user has edited nothing, so there is nothing of theirs to save. + expect(saveButton()).toBeDisabled(); + + // And when they do edit one field, only that field travels: the rename and + // the pause stay as the teammate left them instead of being reverted to + // what this dialog happened to be showing. + await user.click(screen.getByRole("button", { name: "At a time" })); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + const patch = mockUpdateTrigger.mock.calls[0]?.[0]; + expect(patch.label).toBeUndefined(); + expect(patch.enabled).toBeUndefined(); + expect(patch.cron_expression).toBeDefined(); + }); +}); diff --git a/packages/views/autopilots/components/edit-schedule-trigger-dialog.tsx b/packages/views/autopilots/components/edit-schedule-trigger-dialog.tsx new file mode 100644 index 00000000000..d8caaae90d6 --- /dev/null +++ b/packages/views/autopilots/components/edit-schedule-trigger-dialog.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useUpdateAutopilotTrigger } from "@multica/core/autopilots/mutations"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { Button } from "@multica/ui/components/ui/button"; +import { Switch } from "@multica/ui/components/ui/switch"; +import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog"; +import { toast } from "sonner"; +import type { AutopilotTrigger } from "@multica/core/types"; +import { ScheduleEditor } from "./schedule-editor/schedule-editor"; +import { parseCron, toCron } from "./schedule-editor/cron-mapping"; +import { useScheduleSubmitGate } from "./schedule-editor/validate"; +import type { ScheduleConfig } from "./schedule-editor/model"; +import { useT } from "../../i18n"; + +// The only place in the UI where an existing schedule can be changed. The +// autopilot dialog's panel speaks for the autopilot's one schedule; a trigger +// row speaks for itself, which is what an autopilot carrying several of them +// needs (MUL-7478). Mounted per open so the editor always hydrates from the +// row as it stands now — a stale snapshot here would write back a cron the +// user never saw. +export function EditScheduleTriggerDialog({ + open, + onOpenChange, + autopilotId, + trigger, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + autopilotId: string; + trigger: AutopilotTrigger; +}) { + if (!open) return null; + return ( + + ); +} + +function EditScheduleTriggerDialogBody({ + onOpenChange, + autopilotId, + trigger, +}: { + onOpenChange: (open: boolean) => void; + autopilotId: string; + trigger: AutopilotTrigger; +}) { + const { t } = useT("autopilots"); + const wsId = useWorkspaceId(); + const updateTrigger = useUpdateAutopilotTrigger(); + // `parseCron` round-trips anything the server stored: an expression outside + // the structured model comes back as an advanced config holding the raw + // fields, which the editor renders in its expression row. So every schedule + // row is editable here, not only the ones the pickers can describe. + const initialCfg = parseCron(trigger.cron_expression ?? "", trigger.timezone ?? "UTC"); + const [config, setConfig] = useState(initialCfg); + const [label, setLabel] = useState(trigger.label ?? ""); + const [enabled, setEnabled] = useState(trigger.enabled); + const [submitting, setSubmitting] = useState(false); + const scheduleGate = useScheduleSubmitGate(wsId); + + // What "changed" is measured against, snapshotted at mount — never read live + // off `trigger`. The detail query refreshes under an open dialog (a teammate + // saving this same row), and a prop moving under an untouched control would + // read as this user's edit: Save would then send the value they never set, + // back over the one that had just landed. + // + // The cron baseline is the editor's own rendering of the stored expression, + // not the stored text: `parseCron` → `toCron` normalizes (a bare cron on a + // zoned row comes back carrying its `TZ=` prefix), so comparing against the + // stored string would call an untouched schedule edited. Server-side that + // reads as a substantive change — republishing the rule version and moving + // this trigger's accountability to whoever opened the dialog, which MUL-4302 + // settled must not happen on a label-only or no-op save. + const baseline = useRef({ + cron: toCron(initialCfg), + timezone: initialCfg.timezone, + // Trimmed like the value submit sends, so a stored label carrying stray + // whitespace is not already an edit the moment the dialog opens. + label: (trigger.label ?? "").trim(), + enabled: trigger.enabled, + }); + const scheduleDirty = + toCron(config) !== baseline.current.cron || + config.timezone !== baseline.current.timezone; + const labelDirty = label.trim() !== baseline.current.label; + const enabledDirty = enabled !== baseline.current.enabled; + const dirty = scheduleDirty || labelDirty || enabledDirty; + // The cron gate only stands between the user and a write that carries a cron. + // A row whose stored expression the server can no longer preview is exactly + // the one a user reaches for this dialog to switch OFF, and a rejection of an + // expression they are not sending must not be what stops them. + const canSubmit = !submitting && dirty && (!scheduleDirty || scheduleGate.scheduleValid); + + const handleSubmit = async () => { + if (!canSubmit) return; + setSubmitting(true); + try { + let cronExpr: string | null = null; + if (scheduleDirty) { + if (!(await scheduleGate.ensureAccepted(config))) { + setSubmitting(false); + return; + } + cronExpr = toCron(config); + if (!cronExpr.trim()) { + setSubmitting(false); + return; + } + } + // Only the fields that moved. The PATCH preserves everything it is not + // sent, so a field left out here keeps whatever the row has now — which + // is also what makes this dialog safe to have open while someone else + // edits the same trigger: it can only overwrite what its user touched. + await updateTrigger.mutateAsync({ + autopilotId, + triggerId: trigger.id, + ...(cronExpr !== null + ? { cron_expression: cronExpr, timezone: config.timezone || undefined } + : {}), + ...(labelDirty ? { label: label.trim() } : {}), + ...(enabledDirty ? { enabled } : {}), + }); + toast.success(t(($) => $.edit_trigger_dialog.toast_updated)); + onOpenChange(false); + } catch (err) { + toast.error( + err instanceof Error && err.message + ? err.message + : t(($) => $.edit_trigger_dialog.toast_update_failed), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + + + {t(($) => $.edit_trigger_dialog.title)} + {/* Same min-w-0 as the add dialog: the cron readback is one unbreakable + line that would otherwise push the grid track past the dialog. */} +
+ { + scheduleGate.clearRejection(); + setConfig(next); + }} + wsId={wsId} + onValidityChange={scheduleGate.onValidityChange} + // Same reason as the other two schedule dialogs: submit validates + // over the network and then writes what it read going in, so an + // edit landing inside that window would be discarded silently. + disabled={submitting} + /> + +
+ + setLabel(e.target.value)} + placeholder={t(($) => $.edit_trigger_dialog.label_placeholder)} + // Same lock as the editor above, for the same reason: submit reads + // the label going in and validates over the network before + // writing, so a label typed inside that window would be dropped — + // silently, under the success toast for the write that shipped + // without it. + disabled={submitting} + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-body outline-none focus:ring-1 focus:ring-ring disabled:opacity-50" + /> +
+ +
+ + {t(($) => $.edit_trigger_dialog.enabled_label)} + + $.edit_trigger_dialog.enabled_label)} + /> +
+ +
+ +
+
+
+
+ ); +} diff --git a/packages/views/autopilots/components/run-now-toast.test.ts b/packages/views/autopilots/components/run-now-toast.test.ts index d99b50f7426..87fde1c2f5c 100644 --- a/packages/views/autopilots/components/run-now-toast.test.ts +++ b/packages/views/autopilots/components/run-now-toast.test.ts @@ -40,6 +40,12 @@ describe("runNowBlockedKey", () => { ); }); + it("maps runtime_access_denied to its dedicated key (PUCK-89)", () => { + expect(runNowBlockedKey("runtime_access_denied")).toBe( + "run_blocked_runtime_access_denied", + ); + }); + it("degrades an unknown or absent code to the generic message", () => { expect(runNowBlockedKey("some_future_code")).toBe("run_blocked_generic"); expect(runNowBlockedKey(undefined)).toBe("run_blocked_generic"); diff --git a/packages/views/autopilots/components/run-now-toast.ts b/packages/views/autopilots/components/run-now-toast.ts index 535b8791ef9..7b60ebda031 100644 --- a/packages/views/autopilots/components/run-now-toast.ts +++ b/packages/views/autopilots/components/run-now-toast.ts @@ -33,6 +33,7 @@ export type RunNowBlockedKey = | "run_blocked_invocation_not_allowed" | "run_blocked_runtime_offline" | "run_blocked_agent_runtime_required" + | "run_blocked_runtime_access_denied" | "run_blocked_target_unavailable" | "run_blocked_attribution" | "run_blocked_already_active" @@ -50,6 +51,10 @@ export function runNowBlockedKey(reasonCode: string | undefined): RunNowBlockedK // to a runtime (MUL-5559). case "agent_runtime_required": return "run_blocked_agent_runtime_required"; + // Bound but not permitted there: the agent's owner cannot execute it on + // the selected private runtime. Retrying never fixes this. + case "runtime_access_denied": + return "run_blocked_runtime_access_denied"; case "target_unavailable": return "run_blocked_target_unavailable"; case "attribution_blocked": diff --git a/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx b/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx index b456faae21a..7b0ebba9bc1 100644 --- a/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx +++ b/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx @@ -66,7 +66,6 @@ export interface ScheduleEditorProps { onChange: (value: ScheduleConfig) => void; wsId: string; disabled?: boolean; - disabledReason?: string; /** Fires when the server accepts or rejects the current expression, so the * owning dialog can keep its submit button in step with the inline error. */ onValidityChange?: (valid: boolean) => void; @@ -238,7 +237,6 @@ export function ScheduleEditor({ onChange, wsId, disabled, - disabledReason, onValidityChange, }: ScheduleEditorProps) { const { t, i18n } = useT("autopilots"); @@ -862,9 +860,6 @@ export function ScheduleEditor({
)}
- {disabled === true && disabledReason !== undefined && ( -

{disabledReason}

- )}
); } diff --git a/packages/views/autopilots/components/trigger-row.test.tsx b/packages/views/autopilots/components/trigger-row.test.tsx new file mode 100644 index 00000000000..7e57cd37aaf --- /dev/null +++ b/packages/views/autopilots/components/trigger-row.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { AutopilotTrigger } from "@multica/core/types"; +import { renderWithI18n } from "../../test/i18n"; + +// The detail page's trigger row: what a schedule row says about itself, and +// the edit entry it grew in MUL-7478. + +vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test" })); +vi.mock("@multica/core/paths", () => ({ + useWorkspacePaths: () => ({}), + useCurrentWorkspace: () => ({ name: "Acme" }), +})); + +vi.mock("@multica/core/autopilots/queries", () => ({ + autopilotDetailOptions: () => ({ queryKey: ["autopilot"], queryFn: async () => null }), + autopilotRunsOptions: () => ({ queryKey: ["runs"], queryFn: async () => [] }), + autopilotRunOptions: () => ({ queryKey: ["run"], queryFn: async () => null }), + cronPreviewOptions: (wsId: string, expr: string, tz: string) => ({ + queryKey: ["cron-preview", wsId, expr, tz], + queryFn: async () => ({ next_runs: ["2126-07-14T01:00:00Z"] }), + retry: false, + }), +})); + +vi.mock("@multica/core/autopilots/mutations", () => ({ + useUpdateAutopilot: () => ({ mutateAsync: vi.fn() }), + useDeleteAutopilot: () => ({ mutateAsync: vi.fn() }), + useTriggerAutopilot: () => ({ mutateAsync: vi.fn() }), + useCreateAutopilotTrigger: () => ({ mutateAsync: vi.fn() }), + useDeleteAutopilotTrigger: () => ({ mutateAsync: vi.fn() }), + useUpdateAutopilotTrigger: () => ({ mutateAsync: vi.fn() }), + useRotateAutopilotTriggerWebhookToken: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +// A webhook row composes its URL from the API base; everything else in this +// module (ApiError, which the schedule gate type-checks against) stays real. +vi.mock("@multica/core/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, getBaseUrl: () => "https://api.test" } }; +}); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock("./pickers/timezone-picker", () => ({ + TimezonePicker: ({ value }: { value: string }) =>
{value}
, +})); + +import { TriggerRow } from "./autopilot-detail-page"; + +const AUTOPILOT_ID = "ap-1"; + +function trigger(overrides: Partial = {}): AutopilotTrigger { + return { + id: "trg-morning", + autopilot_id: AUTOPILOT_ID, + kind: "schedule", + enabled: true, + cron_expression: "TZ=Asia/Bangkok 0 9 * * *", + timezone: "Asia/Bangkok", + next_run_at: "2126-07-14T02:00:00Z", + webhook_token: null, + label: null, + last_fired_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function renderRow(trig: AutopilotTrigger, canWrite = true) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderWithI18n( + + + , + ); +} + +describe("TriggerRow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads out the next run of a live schedule", () => { + renderRow(trigger()); + + expect(screen.getByText(/Next:/)).toBeInTheDocument(); + expect(screen.queryByText("Disabled")).not.toBeInTheDocument(); + }); + + it("stops promising a next run once the schedule is paused", () => { + // The server keeps next_run_at on a disabled trigger — the dispatcher + // filters on `enabled` rather than clearing the column — so the row used to + // carry the Disabled badge and "Next: ..." at the same time, one of them a + // run that will never happen. + renderRow(trigger({ enabled: false })); + + expect(screen.getByText("Disabled")).toBeInTheDocument(); + expect(screen.queryByText(/Next:/)).not.toBeInTheDocument(); + }); + + it("opens the editor on the schedule this row runs", async () => { + const user = userEvent.setup(); + renderRow(trigger()); + + await user.click(screen.getByRole("button", { name: "Edit schedule" })); + + expect(await screen.findByText("Edit schedule", { selector: "h2" })).toBeInTheDocument(); + expect(screen.getByTestId("timezone-picker")).toHaveTextContent("Asia/Bangkok"); + }); + + it("offers no schedule editor on a trigger that has no cron to edit", () => { + // cron_expression / timezone are rejected on any other kind, so a webhook + // row must not offer an entry that could only fail. + renderRow(trigger({ kind: "webhook", cron_expression: null, timezone: null, next_run_at: null })); + + expect(screen.queryByRole("button", { name: "Edit schedule" })).not.toBeInTheDocument(); + }); + + it("offers no edit entry to a reader who cannot write", () => { + renderRow(trigger(), false); + + expect(screen.queryByRole("button", { name: "Edit schedule" })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/views/autopilots/components/webhook-event-filter-section.tsx b/packages/views/autopilots/components/webhook-event-filter-section.tsx index a451c61f38f..d8526188d3b 100644 --- a/packages/views/autopilots/components/webhook-event-filter-section.tsx +++ b/packages/views/autopilots/components/webhook-event-filter-section.tsx @@ -20,7 +20,9 @@ export function WebhookEventFilterSection({ const [newActions, setNewActions] = useState(""); const docsHref = i18n.language?.startsWith("zh") ? `https://multica.ai/docs/zh/autopilots#${encodeURIComponent("事件过滤")}` - : "https://multica.ai/docs/autopilots#event-filters"; + : i18n.language?.startsWith("fr") + ? `https://multica.ai/docs/fr/autopilots#${encodeURIComponent("filtres-dévénements")}` + : "https://multica.ai/docs/autopilots#event-filters"; const addFilter = () => { const event = newEvent.trim(); diff --git a/packages/views/autopilots/components/workspace-wakeups.test.tsx b/packages/views/autopilots/components/workspace-wakeups.test.tsx new file mode 100644 index 00000000000..e1286a96b40 --- /dev/null +++ b/packages/views/autopilots/components/workspace-wakeups.test.tsx @@ -0,0 +1,246 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { api } from "@multica/core/api"; +import type { + WorkspaceWakeup, + WorkspaceWakeupFilters, +} from "@multica/core/types"; +import { renderWithI18n } from "../../test/i18n"; +import { WorkspaceWakeups } from "./workspace-wakeups"; + +vi.mock("../../common/use-viewing-timezone", () => ({ + useViewingTimezone: () => "UTC", +})); +vi.mock("@multica/core/api", () => ({ + api: { + listWorkspaceWakeups: vi.fn(), + disableIssueWakeup: vi.fn(), + enableIssueWakeup: vi.fn(), + listIssueWakeups: vi.fn(), + editIssueWakeupInstruction: vi.fn(), + }, +})); + +vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws" })); +vi.mock("@multica/core/paths", () => ({ + useWorkspacePaths: () => ({ + issueDetail: (id: string) => `/ws/issues/${id}`, + }), +})); +vi.mock("../../navigation", () => ({ + AppLink: ({ + href, + children, + ...props + }: { + href: string; + children: ReactNode; + }) => ( + + {children} + + ), +})); +vi.mock("../../common/actor-avatar", () => ({ ActorAvatar: () => null })); +vi.mock("../../common/task-transcript", () => ({ + TranscriptButton: () => , +})); + +let rows: WorkspaceWakeup[]; +let queries: WorkspaceWakeupFilters[]; +const list = vi.mocked(api.listWorkspaceWakeups); +const disable = vi.mocked(api.disableIssueWakeup); +const enable = vi.mocked(api.enableIssueWakeup); +function mount() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return renderWithI18n( + + + , + ); +} +beforeEach(() => { + queries = []; + rows = ["a", "b"].map((id) => ({ + id, + issue_id: `issue-${id}`, + issue_identifier: `DEV-${id}`, + issue_title: `Issue ${id}`, + issue_closed: false, + agent_id: id, + agent_name: `Agent ${id}`, + can_manage: true, + active_runs: 0, + task: null, + kind: "event", + mode: "continuous", + event_types: ["task.completed"], + filter_agent_id: null, + filter_task_id: null, + interval_seconds: null, + cron_expression: null, + timezone: "UTC", + next_fire_at: null, + enabled: true, + revision: 1, + disabled_at: null, + last_task_id: null, + last_error: null, + })); + list.mockReset().mockImplementation(async (filters) => { + queries.push(filters); + return { + items: rows, + total: 60, + counts: { all: 60, active: 58, disabled: 1, ended: 1 }, + agents: [ + { id: "a", name: "Agent a" }, + { id: "b", name: "Agent b" }, + ], + }; + }); + disable.mockReset().mockResolvedValue(undefined); + enable.mockReset().mockResolvedValue(undefined); +}); + +it("shows consumed running work separately from its off state and keeps transcript access", async () => { + rows[0] = { + ...rows[0]!, + enabled: false, + mode: "once", + last_task_id: "run", + active_runs: 1, + task: { + id: "run", + agent_id: "a", + issue_id: "issue-a", + runtime_id: "runtime", + status: "running", + priority: 0, + created_at: new Date().toISOString(), + started_at: new Date().toISOString(), + dispatched_at: null, + completed_at: null, + result: null, + error: null, + }, + }; + mount(); + const row = await screen.findByRole("row", { name: /Issue a/ }); + expect(within(row).getByText("Running")).toBeVisible(); + expect(within(row).getByText("Triggered")).toBeVisible(); + expect(within(row).queryByText("Completed")).toBeNull(); + expect(within(row).getByRole("button", { name: "Transcript" })).toBeVisible(); + expect( + within(row).getByRole("button", { name: "Enable again" }), + ).toBeDisabled(); + expect(within(row).getByRole("checkbox")).toHaveAttribute( + "aria-disabled", + "true", + ); + expect(within(row).getByRole("link", { name: /Issue a/ })).toHaveAttribute( + "href", + "/ws/issues/issue-a", + ); +}); + +it("confirms batch consequences and retains only failed selections for retry", async () => { + disable.mockImplementation(async (_issue, id) => { + if (id === "b") throw new Error("forbidden"); + }); + mount(); + await screen.findByText("Issue a"); + fireEvent.click( + screen.getByRole("checkbox", { + name: "Select enabled wakeups on this page", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Turn off selected" })); + expect( + screen.getByText(/Dispatched runs will continue/), + ).toBeVisible(); + expect(disable).not.toHaveBeenCalled(); + fireEvent.click( + screen.getByRole("button", { name: "Turn off" }), + ); + await screen.findByText("Turned off 1; 1 failed. Failed items remain selected for retry."); + expect(disable.mock.calls).toEqual([ + ["issue-a", "a"], + ["issue-b", "b"], + ]); + expect( + screen.getByRole("checkbox", { name: "Select DEV-a, Agent a" }), + ).not.toBeChecked(); + expect( + screen.getByRole("checkbox", { name: "Select DEV-b, Agent b" }), + ).toBeChecked(); +}); + +it("resets page and selection on scope or search changes and sends bounded page requests", async () => { + mount(); + await screen.findByText("Issue a"); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + await waitFor(() => expect(queries.at(-1)?.offset).toBe(50)); + await screen.findByText("Issue a"); + fireEvent.click( + screen.getByRole("checkbox", { name: "Select DEV-a, Agent a" }), + ); + fireEvent.click(screen.getByRole("button", { name: /^All\s*60/ })); + await waitFor(() => + expect(queries.at(-1)).toMatchObject({ + scope: "all", + offset: 0, + limit: 50, + }), + ); + expect( + screen.queryByRole("button", { name: "Turn off selected" }), + ).toBeNull(); + fireEvent.change(screen.getByRole("searchbox"), { + target: { value: "CI & release" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Search" })); + await waitFor(() => expect(queries.at(-1)?.search).toBe("CI & release")); +}); + +it("makes read-only rules non-selectable and surfaces inventory failures", async () => { + rows[0]!.can_manage = false; + const view = mount(); + await screen.findByText("Issue a"); + expect( + screen.getByRole("checkbox", { name: "Select DEV-a, Agent a" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + screen.getByRole("switch", { name: "Wakeup for Agent a" }), + ).toHaveAttribute("aria-disabled", "true"); + view.unmount(); + list.mockRejectedValue(new Error("offline")); + mount(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not load wakeups", + ); + expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled(); +}); + + +it("opens the shared prompt editor from a manageable row without loading all prompts", async () => { + const [first, second] = rows; + if (!first || !second) throw new Error("Missing wakeup fixtures"); + vi.mocked(api.listIssueWakeups).mockResolvedValue([{ ...first, instruction: "Inspect the result" }]); + vi.mocked(api.editIssueWakeupInstruction).mockResolvedValue(undefined); + second.can_manage = false; + mount(); + const buttons = await screen.findAllByRole("button", { name: "Edit prompt" }); + expect(buttons[1]).toBeDisabled(); + expect(api.listIssueWakeups).not.toHaveBeenCalled(); + if (!buttons[0]) throw new Error("Missing edit button"); + fireEvent.click(buttons[0]); + const input = await screen.findByRole("textbox", { name: "What to do when woken" }); + fireEvent.change(input, { target: { value: "Inspect and summarize" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(api.editIssueWakeupInstruction).toHaveBeenCalledWith("issue-a", "a", { instruction: "Inspect and summarize", expected_instruction: "Inspect the result", revision: 1 })); +}); diff --git a/packages/views/autopilots/components/workspace-wakeups.tsx b/packages/views/autopilots/components/workspace-wakeups.tsx new file mode 100644 index 00000000000..03588d11509 --- /dev/null +++ b/packages/views/autopilots/components/workspace-wakeups.tsx @@ -0,0 +1,616 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Bell, Clock3, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { useWorkspacePaths } from "@multica/core/paths"; +import { + workspaceWakeupsOptions, + useDisableWorkspaceWakeups, + useDisableIssueWakeup, + useEnableIssueWakeup, +} from "@multica/core/issues/wakeups"; +import type { + WorkspaceWakeup, + WorkspaceWakeupFilters, +} from "@multica/core/types"; +import { Button } from "@multica/ui/components/ui/button"; +import { Checkbox } from "@multica/ui/components/ui/checkbox"; +import { Input } from "@multica/ui/components/ui/input"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@multica/ui/components/ui/select"; +import { + Table, + TableHeader, + TableHead, + TableBody, + TableRow, + TableCell, +} from "@multica/ui/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@multica/ui/components/ui/dialog"; +import { AppLink } from "../../navigation"; +import { useLocale, useT, useTimeAgo } from "../../i18n"; +import { CollectionPageState } from "../../layout/collection-page"; +import { ActorAvatar } from "../../common/actor-avatar"; +import { TranscriptButton } from "../../common/task-transcript"; +import { useViewingTimezone } from "../../common/use-viewing-timezone"; +import { WakeupInstructionEditor } from "../../issues/components/wakeup-instruction-editor"; +import { WakeupControl } from "../../issues/components/wakeup-control"; +import { + isActiveWakeupRun, + useWakeupText, +} from "../../issues/components/wakeup-presentation"; + +function WakeupListRow({ + row, + selected, + onSelect, + busy, +}: { + row: WorkspaceWakeup; + selected: boolean; + onSelect: () => void; + busy: boolean; +}) { + const { t } = useT("autopilots"); + const { t: ti } = useT("issues"); + const wsId = useWorkspaceId(); + const paths = useWorkspacePaths(); + const locale = useLocale(); + const timeAgo = useTimeAgo(); + const text = useWakeupText(); + const viewTZ = useViewingTimezone(); + const disable = useDisableIssueWakeup(wsId, row.issue_id); + const enable = useEnableIssueWakeup(wsId, row.issue_id); + const Icon = row.kind === "event" ? Bell : Clock3; + const next = text.state(row, row.issue_closed); + const status = row.task?.status; + return ( + + + $.wakeups.select_row, { + issue: row.issue_identifier, + agent: row.agent_name, + })} + /> + + + + + {row.issue_title} + + + {row.issue_identifier} + + + + + + + {row.agent_name} + + + + + text.eventCondition(event, row), + ) + .join(", ")} + > + + {text.trigger(row)} + + + {row.kind === "cron" ? row.timezone : text.frequency(row)} + + + + + {next} + + {row.last_error && ( + + {ti(($) => $.wakeups.needs_attention)} + + )} + + + {row.task ? ( +
+
+ + {text.runState(status)} + + + {row.active_runs > 1 + ? t(($) => $.wakeups.active_runs, { count: row.active_runs }) + : timeAgo( + row.task.completed_at ?? + row.task.started_at ?? + row.task.created_at, + )} + +
+ $.wakeups.last_run)} + isLive={isActiveWakeupRun(status)} + /> +
+ ) : ( + {text.runState()} + )} +
+ +
$.wakeups.read_only) + : row.issue_closed + ? ti(($) => $.wakeups.closed_hint) + : undefined + } + > + + disable.mutate(row.id, { + onError: (err) => + toast.error( + text.error( + err, + ti(($) => $.wakeups.disable_error), + ), + ), + }) + } + onEnable={async (input = {}) => { + await enable.mutateAsync({ + id: row.id, + revision: row.revision ?? 0, + ...input, + }); + }} + /> +
+
+ + + +
+ ); +} + +export function WorkspaceWakeups() { + const { t } = useT("autopilots"); + const { t: ti } = useT("issues"); + const wsId = useWorkspaceId(); + const [filters, setFilters] = useState({ + scope: "active", + kind: "all", + search: "", + agent_id: "", + offset: 0, + limit: 50, + }); + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState>(new Set()); + const [confirmation, setConfirmation] = useState([]); + const [batchResult, setBatchResult] = useState<{ + failed: string[]; + succeeded: number; + } | null>(null); + const query = useQuery(workspaceWakeupsOptions(wsId, filters)); + const batch = useDisableWorkspaceWakeups(wsId); + const rows = query.data?.items ?? []; + const selectable = rows.filter((row) => row.enabled && row.can_manage); + const picked = selectable.filter((row) => selected.has(row.id)); + const change = (patch: Partial) => { + setFilters((prev) => ({ ...prev, offset: 0, ...patch })); + setSelected(new Set()); + setBatchResult(null); + }; + const kinds = [ + { value: "all", label: t(($) => $.wakeups.all_triggers) }, + { value: "event", label: t(($) => $.wakeups.event) }, + { value: "at", label: t(($) => $.wakeups.at) }, + { value: "recurring", label: t(($) => $.wakeups.recurring) }, + ]; + const agents = [ + { value: "", label: t(($) => $.wakeups.all_agents) }, + ...(query.data?.agents ?? []).map((a) => ({ value: a.id, label: a.name })), + ]; + return ( + <> +
+
$.wakeups.scope)} + > + {(["active", "all", "disabled", "ended"] as const).map((scope) => ( + + ))} +
+
+
{ + event.preventDefault(); + change({ search: search.trim() }); + }} + className="flex items-center gap-1" + > + { + setSearch(event.target.value); + if (!event.target.value) change({ search: "" }); + }} + aria-label={t(($) => $.wakeups.search)} + placeholder={t(($) => $.wakeups.search)} + /> + +
+ + +
+
+ {batchResult && ( +

+ {t( + ($) => + batchResult.failed.length + ? $.wakeups.batch_partial + : $.wakeups.batch_success, + { + count: batchResult.succeeded, + succeeded: batchResult.succeeded, + failed: batchResult.failed.length, + }, + )} +

+ )} + {query.isError ? ( + $.wakeups.load_error)} + actions={ + + } + /> + ) : query.isPending ? ( + $.wakeups.loading)} + /> + ) : !rows.length ? ( + + (query.data?.counts.all ?? 0) > 0 || + filters.search || + filters.kind !== "all" || + filters.agent_id + ? $.wakeups.empty_filtered + : $.wakeups.empty, + )} + actions={ + (filters.scope !== "all" || + !!filters.search || + filters.kind !== "all" || + !!filters.agent_id) && + (query.data?.counts.all ?? 0) > 0 ? ( + + ) : undefined + } + /> + ) : ( +
+ + + + + 0 && picked.length === selectable.length + } + indeterminate={ + picked.length > 0 && picked.length < selectable.length + } + onCheckedChange={() => + setSelected( + picked.length === selectable.length + ? new Set() + : new Set(selectable.map((row) => row.id)), + ) + } + aria-label={t(($) => $.wakeups.select_page)} + /> + + {t(($) => $.wakeups.issue)} + {t(($) => $.wakeups.target_agent)} + {t(($) => $.wakeups.trigger)} + {t(($) => $.wakeups.next)} + {t(($) => $.wakeups.wakeup_run)} + + {t(($) => $.wakeups.enabled)} + + + + {ti(($) => $.wakeups.edit_instruction)} + + + + + + {rows.map((row) => ( + + setSelected((prev) => { + const next = new Set(prev); + if (next.has(row.id)) next.delete(row.id); + else next.add(row.id); + return next; + }) + } + /> + ))} + +
+
+ )} +
+ {picked.length > 0 && ( + <> + + {t(($) => $.wakeups.selected, { count: picked.length })} + + + + + )} + + {query.data && !query.isError + ? t(($) => $.wakeups.results, { + count: query.data?.total ?? 0, + page: Math.floor(filters.offset / filters.limit) + 1, + }) + : "—"} + + + +
+ 0} + onOpenChange={(open) => { + if (!open && !batch.isPending) setConfirmation([]); + }} + > + + + + {t(($) => $.wakeups.confirm_title, { + count: confirmation.length, + })} + + + {t(($) => $.wakeups.confirm_body)} + + + + + + + + + + ); +} diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index df62d30e1be..3fc6eb1cd63 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -392,7 +392,7 @@ export function ChatPage() { > { expect(await screen.findByText("Draft ready.")).toBeInTheDocument(); expect(screen.queryByText(/Hidden suggestion/)).not.toBeInTheDocument(); }); + + it("keeps attachments visible when a settled content transform removes their inline reference", async () => { + const attachmentId = "11111111-2222-3333-4444-555555555555"; + render( + + + !file[report.pdf](/api/attachments/${attachmentId}/download)` + + "Visible answer", + task_id: null, + created_at: "2026-09-17T00:00:00Z", + attachments: [pdfAttachment(attachmentId)], + }]} + pendingTask={null} + availability="online" + transformContent={(content) => + content.replace(/[\s\S]*<\/agent_draft>/, "") + } + /> + + , + ); + + expect(await screen.findByText("Visible answer")).toBeInTheDocument(); + expect(screen.getByText("report.pdf")).toBeInTheDocument(); + }); + + it("does not offer Copy for an attachment-only reply", async () => { + render( + + + + + , + ); + + expect(await screen.findByText("report.pdf")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument(); + }); + + it("renders the canonical settled answer while retaining process narration", async () => { + const qc = new QueryClient(); + qc.setQueryData(chatKeys.taskMessages(TASK_ID), [ + taskMsg(0, "text", { content: "first timeline fragment" }), + taskMsg(1, "thinking", { content: "checking" }), + taskMsg(2, "text", { content: "second timeline fragment" }), + ]); + + render( + + + + + , + ); + + expect(await screen.findByText("Complete canonical answer")).toBeInTheDocument(); + expect(screen.getAllByText("Complete canonical answer")).toHaveLength(1); + const foldTrigger = screen.getByText("1 step"); + expect(screen.queryByText("first timeline fragment")).not.toBeInTheDocument(); + expect(screen.queryByText("second timeline fragment")).not.toBeInTheDocument(); + + fireEvent.click(foldTrigger); + expect(screen.getByText("first timeline fragment")).toBeInTheDocument(); + expect(screen.queryByText("second timeline fragment")).not.toBeInTheDocument(); + expect(screen.getAllByText("Complete canonical answer")).toHaveLength(1); + }); + + it("keeps the answer node mounted across the live-to-settled handoff", async () => { + const qc = new QueryClient(); + qc.setQueryData(chatKeys.taskMessages(TASK_ID), [ + taskMsg(0, "tool_use", { tool: "Read", input: { path: "/tmp/x" } }), + taskMsg(1, "text", { content: "Intermediate narration" }), + taskMsg(2, "thinking", { content: "Checking the result" }), + taskMsg(3, "text", { content: "Stable final answer" }), + ]); + + const view = ( + messages: Parameters[0]["messages"], + pendingTask: Parameters[0]["pendingTask"], + ) => ( + + + + + + ); + + const { rerender } = render( + view([], { task_id: TASK_ID, status: "running" }), + ); + const answerBefore = await screen.findByText("Stable final answer"); + expect(screen.getByText("3 steps")).toBeInTheDocument(); + + rerender(view([{ + id: "persisted-answer", + chat_session_id: "session-1", + role: "assistant", + content: "Stable final answer", + task_id: TASK_ID, + created_at: "2026-09-17T00:00:00Z", + }], null)); + + expect(await screen.findByText("Stable final answer")).toBe(answerBefore); + expect(screen.getByText("3 steps")).toBeInTheDocument(); + expect(screen.queryByText("Intermediate narration")).not.toBeInTheDocument(); + }); }); describe("ChatMessageList footer spacing", () => { @@ -585,6 +746,14 @@ describe("ChatMessageList failure copy (MUL-5370 regression)", () => { expect(screen.queryByText(FALLBACK)).not.toBeInTheDocument(); }); + it("renders dedicated recovery copy for persisted runtime access denial", async () => { + renderFailure("runtime_access_denied"); + expect( + await screen.findByText(enChat.message_list.failure.runtime_access_denied), + ).toBeInTheDocument(); + expect(screen.queryByText(FALLBACK)).not.toBeInTheDocument(); + }); + it("renders dedicated copy for a refined reason the map names", async () => { renderFailure("agent_error.provider_network"); expect( diff --git a/packages/views/chat/components/chat-message-list.tsx b/packages/views/chat/components/chat-message-list.tsx index 6af5b8c14b1..292f55c794d 100644 --- a/packages/views/chat/components/chat-message-list.tsx +++ b/packages/views/chat/components/chat-message-list.tsx @@ -33,8 +33,7 @@ import { RichContent } from "../../rich-content"; import { RichContentScrollRootProvider } from "../../rich-content/scroll-root"; import { copyText } from "@multica/ui/lib/clipboard"; import { AttachmentList } from "../../issues/components/comment-card"; -import { ImageSequenceProvider } from "../../editor"; -import { collectImageSequence } from "@multica/core/attachments/image-sequence"; +import { PreviewSequenceProvider, collectPreviewSequence } from "../../editor"; import type { AgentAvailability } from "@multica/core/agents"; import { resolveFailureReasonKey } from "@multica/core/agents"; import type { @@ -51,7 +50,11 @@ import { CHAT_COLUMN, CHAT_GUTTER } from "./chat-column"; import { FOLLOW_EDGE_THRESHOLD } from "../../common/task-transcript/transcript-follow"; import { LIVE_END_ROW_ATTR, useStickToBottom } from "./stick-to-bottom"; import { formatElapsedMs } from "../lib/format"; -import { splitTimeline, extractCopyText } from "../lib/copy-text"; +import { + canonicalAnswerText, + extractCopyText, + splitTimeline, +} from "../lib/copy-text"; import { stripChatQuickActionsProtocol } from "../lib/quick-actions"; import { useT } from "../../i18n"; @@ -284,17 +287,17 @@ export function ChatMessageList({ availability, }; - // Every image in this session, in message order, so opening one lets the - // reader page through the rest (MUL-5752). Built from the message data, not - // from what Virtuoso currently has mounted. + // Every previewable file in this session, in message order, so opening one + // lets the reader page through the rest (MUL-5752). Built from the message + // data, not from what Virtuoso currently has mounted. // // Persisted messages only: a task transcript's own attachments live behind a - // separate query and its blocks are collapsed by default, so an image in - // there keeps its standalone preview instead of entering a sequence the - // reader can't see the rest of. - const imageSequence = useMemo( + // separate query and its blocks are collapsed by default, so a file in there + // keeps its standalone preview instead of entering a sequence the reader + // can't see the rest of. + const previewSequence = useMemo( () => - collectImageSequence( + collectPreviewSequence( messages.map((message) => ({ content: message.content, attachments: message.attachments, @@ -304,7 +307,7 @@ export function ChatMessageList({ ); return ( - +
)}
-
+ ); } @@ -599,6 +602,14 @@ function AssistantMessage({ // without any text. Keep whatever tool/thinking timeline the run produced and // show a localized "no text reply" notice instead of an empty markdown block. const isNoResponse = message?.message_kind === "no_response"; + const settledContent = message + ? canonicalAnswerText(message, transformContent) + : undefined; + // Empty persisted content is valid for attachment-only/no-response turns and + // for legacy rows whose transcript is the only remaining text source. Only + // a non-empty canonical answer replaces timeline text after settlement. + const canonicalAnswer = + !isNoResponse && settledContent?.trim() ? settledContent : undefined; return (
@@ -608,13 +619,14 @@ function AssistantMessage({ attachments={message?.attachments} phase={phase} isStreaming={!message} + settledContent={canonicalAnswer} /> )} {isNoResponse ? ( ) : message && timeline.length === 0 ? ( {onQuickAction && showStarterCards ? ( // The opening's starter cards own this turn's suggestion strip @@ -853,15 +866,18 @@ function MessageFooter({ message, timeline, isPending, + transformContent, }: { message: ChatMessage; timeline: ChatTimelineItem[]; isPending: boolean; + transformContent?: (content: string) => string; }) { // A no_response turn has nothing to copy, and its caption uses a neutral // "Finished in Xs" instead of "Replied in Xs" (MUL-4351). const isNoResponse = message.message_kind === "no_response"; - const showCopy = !isPending && !isNoResponse; + const copyContent = extractCopyText(message, timeline, transformContent); + const showCopy = !isPending && !isNoResponse && copyContent.trim().length > 0; if (message.elapsed_ms == null && !showCopy) return null; return (
@@ -871,21 +887,21 @@ function MessageFooter({ elapsedMs={message.elapsed_ms} /> )} - {showCopy && } + {showCopy && ( + + )}
); } function MessageCopyButton({ - message, - timeline, + content, }: { - message: ChatMessage; - timeline: ChatTimelineItem[]; + content: string; }) { const { t } = useT("chat"); const handleCopy = async () => { - if (await copyText(extractCopyText(message, timeline))) { + if (await copyText(content)) { toast.success(t(($) => $.message_list.copied_toast)); } else { toast.error(t(($) => $.message_list.copy_failed_toast)); @@ -978,6 +994,7 @@ function FailureBubble({ timeout: t(($) => $.message_list.failure.timeout), codex_semantic_inactivity: t(($) => $.message_list.failure.codex_semantic_inactivity), runtime_offline: t(($) => $.message_list.failure.runtime_offline), + runtime_access_denied: t(($) => $.message_list.failure.runtime_access_denied), runtime_recovery: t(($) => $.message_list.failure.runtime_recovery), manual: t(($) => $.message_list.failure.manual), cancelled: t(($) => $.message_list.failure.manual), @@ -1041,37 +1058,66 @@ function FailureBubble({ ); } -// ─── Timeline: outer process fold + final text (Conductor-style) ───────── +// ─── Timeline: outer process fold + answer (Conductor-style) ───────────── // -// splitTimeline (lib/copy-text.ts) carves the items into: +// While streaming, splitTimeline (lib/copy-text.ts) carves the items into: // preface — text before the first thinking/tool item // middle — first → last non-text item (inclusive, may sandwich text) // final — text after the last non-text item // -// We render preface + final outside an outer Collapsible ("X steps") that -// wraps middle. The inner row Collapsibles (ThinkingRow / ToolCallRow / -// ToolResultRow) are unchanged — clicking them toggles independently of -// the outer fold. Copy mirrors what's visible when the outer fold is -// closed: preface + final, never middle. See extractCopyText for the -// authoritative copy logic. +// Once settled, the persisted chat_message content is authoritative for the +// answer. Preface + middle remain in the process fold so intermediate narration +// is still inspectable; only trailing transcript text is replaced. Explicit +// process/answer keys preserve the trailing RichContent subtree when a live row +// becomes its persisted row (MUL-4922). function TimelineView({ items, isStreaming, attachments, phase = "settled", + settledContent, }: { items: ChatTimelineItem[]; isStreaming?: boolean; attachments?: import("@multica/core/types").Attachment[]; phase?: "streaming" | "settled"; + settledContent?: string; }) { + if (phase === "settled" && settledContent !== undefined) { + const { preface, middle } = splitTimeline(items); + const processItems = [...preface, ...middle]; + return ( + <> + {processItems.length > 0 && ( + + )} + + + ); + } + const { preface, middle, final } = splitTimeline(items); return ( <> {preface.length > 0 && ( t.content ?? "").join("")} attachments={attachments} density="compact" @@ -1081,6 +1127,7 @@ function TimelineView({ )} {middle.length > 0 && ( 0 && ( t.content ?? "").join("")} attachments={attachments} density="compact" @@ -1105,11 +1153,13 @@ function OuterProcessFold({ isStreaming, attachments, phase = "settled", + stepCount, }: { items: ChatTimelineItem[]; isStreaming?: boolean; attachments?: import("@multica/core/types").Attachment[]; phase?: "streaming" | "settled"; + stepCount?: number; }) { const { t } = useT("chat"); // Open while the task streams (so the user watches progress), collapsed once @@ -1123,13 +1173,13 @@ function OuterProcessFold({ if (wasStreaming.current && !isStreaming) setOpen(false); wasStreaming.current = !!isStreaming; }, [isStreaming]); - const stepCount = items.length; + const displayedStepCount = stepCount ?? items.length; return ( {open ? : } - {t(($) => $.message_list.process_steps, { count: stepCount })} + {t(($) => $.message_list.process_steps, { count: displayedStepCount })}
diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index 1366924e9d6..8d02c6cca43 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -17,7 +17,7 @@ import { useWorkspaceId } from "@multica/core/hooks"; import { useAuthStore } from "@multica/core/auth"; import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; -import { canAssignAgent } from "@multica/views/issues/components"; +import { canAssignAgent } from "../../issues/components/pickers/assignee-picker"; import { api, dispatchReasonCode } from "@multica/core/api"; import { isAgentRuntimeBound, @@ -494,7 +494,9 @@ export function ChatWindow() { ? t(($) => $.input.send_blocked_toast) : reason === "agent_runtime_required" ? t(($) => $.input.runtime_required_toast) - : t(($) => $.input.send_failed_toast), + : reason === "runtime_access_denied" + ? t(($) => $.input.runtime_access_denied_toast) + : t(($) => $.input.send_failed_toast), ); return false; } @@ -520,7 +522,9 @@ export function ChatWindow() { ? t(($) => $.input.send_blocked_toast) : reason === "agent_runtime_required" ? t(($) => $.input.runtime_required_toast) - : t(($) => $.input.send_failed_toast), + : reason === "runtime_access_denied" + ? t(($) => $.input.runtime_access_denied_toast) + : t(($) => $.input.send_failed_toast), ); return false; } diff --git a/packages/views/chat/components/task-status-pill.tsx b/packages/views/chat/components/task-status-pill.tsx index fe32a9a625f..159a35b2995 100644 --- a/packages/views/chat/components/task-status-pill.tsx +++ b/packages/views/chat/components/task-status-pill.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { cn } from "@multica/ui/lib/utils"; +import { ShimmerText } from "@multica/ui/components/common/shimmer-text"; import { UnicodeSpinner } from "@multica/ui/components/common/unicode-spinner"; import type { AgentAvailability } from "@multica/core/agents"; import type { ChatPendingTask, TaskMessagePayload } from "@multica/core/types"; @@ -216,9 +216,9 @@ export function TaskStatusPill({ )} - + {stage.label} - + · {formatElapsedSecs(elapsedSecs)}
diff --git a/packages/views/chat/components/use-chat-controller.test.tsx b/packages/views/chat/components/use-chat-controller.test.tsx index 12dbe37eeb9..4af687caf74 100644 --- a/packages/views/chat/components/use-chat-controller.test.tsx +++ b/packages/views/chat/components/use-chat-controller.test.tsx @@ -98,7 +98,7 @@ vi.mock("@multica/core/projects/queries", () => ({ // Steerable per test: the invoke rule is what decides whether an OPEN session's // agent is still runnable. Default true so every existing case is unaffected. const invokableAgentIds = vi.hoisted(() => ({ current: null as string[] | null })); -vi.mock("@multica/views/issues/components", () => ({ +vi.mock("../../issues/components/pickers/assignee-picker", () => ({ canAssignAgent: (agent: { id: string }) => invokableAgentIds.current === null || invokableAgentIds.current.includes(agent.id), diff --git a/packages/views/chat/components/use-chat-controller.ts b/packages/views/chat/components/use-chat-controller.ts index 541bc584b92..8018c63af45 100644 --- a/packages/views/chat/components/use-chat-controller.ts +++ b/packages/views/chat/components/use-chat-controller.ts @@ -11,7 +11,7 @@ import { useWorkspaceId } from "@multica/core/hooks"; import { useAuthStore } from "@multica/core/auth"; import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; -import { canAssignAgent } from "@multica/views/issues/components"; +import { canAssignAgent } from "../../issues/components/pickers/assignee-picker"; import { api, dispatchReasonCode } from "@multica/core/api"; import { isAgentRuntimeBound as hasAgentRuntime, @@ -553,7 +553,9 @@ export function useChatController(opts?: { isActive?: boolean }) { ? t(($) => $.input.send_blocked_toast) : reason === "agent_runtime_required" ? t(($) => $.input.runtime_required_toast) - : t(($) => $.input.send_failed_toast), + : reason === "runtime_access_denied" + ? t(($) => $.input.runtime_access_denied_toast) + : t(($) => $.input.send_failed_toast), ); return false; } @@ -583,7 +585,9 @@ export function useChatController(opts?: { isActive?: boolean }) { ? t(($) => $.input.send_blocked_toast) : reason === "agent_runtime_required" ? t(($) => $.input.runtime_required_toast) - : t(($) => $.input.send_failed_toast), + : reason === "runtime_access_denied" + ? t(($) => $.input.runtime_access_denied_toast) + : t(($) => $.input.send_failed_toast), ); return false; } diff --git a/packages/views/chat/lib/copy-text.test.ts b/packages/views/chat/lib/copy-text.test.ts index b6ba987ad2b..61f4f4cc7bd 100644 --- a/packages/views/chat/lib/copy-text.test.ts +++ b/packages/views/chat/lib/copy-text.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest"; import type { ChatMessage } from "@multica/core/types"; import type { ChatTimelineItem } from "@multica/core/chat"; -import { splitTimeline, extractCopyText } from "./copy-text"; +import { + canonicalAnswerText, + extractCopyText, + splitTimeline, +} from "./copy-text"; const text = (seq: number, content: string): ChatTimelineItem => ({ seq, @@ -82,54 +86,38 @@ describe("splitTimeline", () => { }); }); -describe("extractCopyText", () => { - it("falls back to message.content when timeline is empty (legacy)", () => { - expect(extractCopyText(message("legacy body"), [])).toBe("legacy body"); +describe("canonicalAnswerText", () => { + it("uses persisted message content", () => { + expect(canonicalAnswerText(message("legacy body"))).toBe("legacy body"); }); - it("returns concatenated text segments for an all-text timeline", () => { + it("applies a surface transform to hidden protocols", () => { expect( - extractCopyText(message(""), [text(1, "hello"), text(2, "world")]), - ).toBe("hello\n\nworld"); + canonicalAnswerText( + message("visiblehidden"), + (content) => content.replace(/[\s\S]*<\/agent_draft>/, ""), + ), + ).toBe("visible"); }); +}); - it("returns only the final text for the standard tool-using shape", () => { +describe("extractCopyText", () => { + it("copies canonical message content without transcript inference", () => { expect( - extractCopyText(message(""), [ - thinking(1), - tool(2), - text(3, "intermediate — should be excluded"), - tool(4), - text(5, "final answer"), + extractCopyText(message("complete canonical answer"), [ + text(1, "partial timeline answer"), + thinking(2), ]), - ).toBe("final answer"); + ).toBe("complete canonical answer"); }); - it("includes preface and final, excludes middle text", () => { + it("falls back to visible timeline text for legacy empty-content rows", () => { expect( - extractCopyText(message(""), [ - text(1, "preface"), - tool(2), - text(3, "middle — excluded"), - tool(4), - text(5, "final"), - ]), + extractCopyText(message(""), [text(1, "preface"), tool(2), text(3, "final")]), ).toBe("preface\n\nfinal"); }); - it("falls back to message.content when timeline has no text items", () => { - expect( - extractCopyText(message("fallback body"), [thinking(1), tool(2)]), - ).toBe("fallback body"); - }); - - it("joins multiple trailing text segments with blank-line separators", () => { - expect( - extractCopyText(message(""), [ - tool(1), - text(2, "para 1"), - text(3, "para 2"), - ]), - ).toBe("para 1\n\npara 2"); + it("returns empty text for an attachment-only row", () => { + expect(extractCopyText(message(""), [])).toBe(""); }); }); diff --git a/packages/views/chat/lib/copy-text.ts b/packages/views/chat/lib/copy-text.ts index 98bc19e5ef1..350e1ee3eb4 100644 --- a/packages/views/chat/lib/copy-text.ts +++ b/packages/views/chat/lib/copy-text.ts @@ -1,5 +1,6 @@ import type { ChatMessage } from "@multica/core/types"; import type { ChatTimelineItem } from "@multica/core/chat"; +import { stripChatQuickActionsProtocol } from "./quick-actions"; /** * Split an assistant timeline into three regions for the conductor-style fold: @@ -8,10 +9,9 @@ import type { ChatTimelineItem } from "@multica/core/chat"; * including any text items sandwiched between them * final — text items after the last non-text item * - * UI renders preface above the outer fold, middle inside the fold (with each - * row keeping its existing inner Collapsible), and final below the fold. - * Copy concatenates preface + final — the fold's contents are intentionally - * omitted, mirroring what's visible when the fold is closed. + * While streaming, UI renders preface above the outer fold, middle inside the + * fold, and final below it. Once settled, preface + middle become process + * history and the canonical chat message replaces final. */ export function splitTimeline(items: ChatTimelineItem[]): { preface: ChatTimelineItem[]; @@ -34,21 +34,34 @@ export function splitTimeline(items: ChatTimelineItem[]): { } /** - * Markdown source the Copy action puts on the clipboard. By design this is - * the user-visible answer only — anything inside the outer fold (thinking, - * tool calls, sandwiched intermediate text) is dropped. Falls back to - * `message.content` for legacy messages without a timeline and for the - * pathological all-non-text shape so Copy never produces an empty string. + * Canonical completed answer from the persisted chat message. Surface-specific + * transforms still apply so hidden protocols stay out of the rendered and + * copied answer. + */ +export function canonicalAnswerText( + message: ChatMessage, + transformContent?: (content: string) => string, +): string { + const content = stripChatQuickActionsProtocol(message.content ?? ""); + return transformContent ? transformContent(content) : content; +} + +/** + * Markdown source for Copy. Completed messages use canonical content instead + * of inferring an answer from transcript position. Legacy rows with empty + * content retain the previous visible-timeline fallback. */ export function extractCopyText( message: ChatMessage, timeline: ChatTimelineItem[], + transformContent?: (content: string) => string, ): string { - if (timeline.length === 0) return message.content ?? ""; + const canonical = canonicalAnswerText(message, transformContent); + if (canonical.trim()) return canonical; + const { preface, final } = splitTimeline(timeline); - const pieces = [...preface, ...final] - .map((i) => i.content ?? "") - .filter((s) => s.length > 0); - if (pieces.length === 0) return message.content ?? ""; - return pieces.join("\n\n"); + return [...preface, ...final] + .map((item) => item.content ?? "") + .filter((content) => content.length > 0) + .join("\n\n"); } diff --git a/packages/views/common/cli-install-command.test.tsx b/packages/views/common/cli-install-command.test.tsx new file mode 100644 index 00000000000..565574d0891 --- /dev/null +++ b/packages/views/common/cli-install-command.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { + CLI_INSTALL_COMMANDS, + CliInstallCommand, +} from "./cli-install-command"; + +const LABELS = { + group: "Choose your platform", + macosLinux: "macOS / Linux", + windows: "Windows", +}; + +function renderSwitch() { + return render( + + {(command) => {command}} + , + ); +} + +describe("CliInstallCommand", () => { + it("defaults to the macOS / Linux installer", () => { + renderSwitch(); + + expect( + screen.getByRole("tablist", { name: LABELS.group }), + ).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "macOS / Linux" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect( + screen.getByText(CLI_INSTALL_COMMANDS.macosLinux), + ).toBeInTheDocument(); + expect(screen.queryByText(CLI_INSTALL_COMMANDS.windows)).toBeNull(); + }); + + it("hands the Windows command to the row when that tab is picked", async () => { + const user = userEvent.setup(); + renderSwitch(); + + await user.click(screen.getByRole("tab", { name: "Windows" })); + + expect(screen.getByRole("tab", { name: "Windows" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByText(CLI_INSTALL_COMMANDS.windows)).toBeInTheDocument(); + expect(screen.queryByText(CLI_INSTALL_COMMANDS.macosLinux)).toBeNull(); + }); + + // A real tablist: arrow keys move between platforms without a click. + it("switches platform with the arrow keys", async () => { + const user = userEvent.setup(); + renderSwitch(); + + await user.click(screen.getByRole("tab", { name: "macOS / Linux" })); + await user.keyboard("{ArrowRight}"); + + expect(screen.getByText(CLI_INSTALL_COMMANDS.windows)).toBeInTheDocument(); + }); +}); diff --git a/packages/views/common/cli-install-command.tsx b/packages/views/common/cli-install-command.tsx new file mode 100644 index 00000000000..38074b59a91 --- /dev/null +++ b/packages/views/common/cli-install-command.tsx @@ -0,0 +1,85 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@multica/ui/components/ui/tabs"; + +/** + * The Multica CLI install commands, one per platform family. + * + * Three surfaces render install instructions — the runtimes "add computer" + * dialog, onboarding's CLI card, and the landing download page. They read the + * commands from here instead of each hardcoding a string, which is how Windows + * silently went missing from all of them while scripts/install.ps1 sat in the + * repo. The scripts' own headers are the source of truth. + */ +export const CLI_INSTALL_COMMANDS = { + macosLinux: + "curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash", + windows: + "irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex", +} as const; + +export type CliInstallPlatform = keyof typeof CLI_INSTALL_COMMANDS; + +/** Tab order. macOS / Linux stays first so the default matches the old single command. */ +const PLATFORMS = [ + "macosLinux", + "windows", +] as const satisfies readonly CliInstallPlatform[]; + +export interface CliInstallCommandLabels { + /** Accessible name for the platform switch. */ + group: string; + macosLinux: string; + windows: string; +} + +/** + * Platform switch for the install command, built on the shared Tabs primitive + * so tab semantics and arrow-key navigation come for free. + * + * The switch is the only shared UI: `children` receives the selected command + * and renders it, so each surface keeps its own command row — the product + * surfaces their token-styled row with an icon copy button, the landing page + * its marketing palette with a spelled-out one. + */ +export function CliInstallCommand({ + labels, + classNames, + children, +}: { + labels: CliInstallCommandLabels; + /** Palette overrides for surfaces that do not use the product tokens. */ + classNames?: { list?: string; trigger?: string }; + children: (command: string) => ReactNode; +}) { + return ( + + + {PLATFORMS.map((platform) => ( + + {labels[platform]} + + ))} + + {PLATFORMS.map((platform) => ( + + {children(CLI_INSTALL_COMMANDS[platform])} + + ))} + + ); +} diff --git a/packages/views/common/docs-locale.test.ts b/packages/views/common/docs-locale.test.ts new file mode 100644 index 00000000000..e06fc084552 --- /dev/null +++ b/packages/views/common/docs-locale.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { docsLocalePrefix } from "./docs-locale"; + +describe("docsLocalePrefix", () => { + it.each([ + ["en", ""], + [undefined, ""], + ["zh-Hans", "/zh"], + ["ja", "/ja"], + ["ko", "/ko"], + ["fr", "/fr"], + ])("maps %s to %j", (language, expected) => { + expect(docsLocalePrefix(language)).toBe(expected); + }); +}); diff --git a/packages/views/common/docs-locale.ts b/packages/views/common/docs-locale.ts new file mode 100644 index 00000000000..331c362ba0e --- /dev/null +++ b/packages/views/common/docs-locale.ts @@ -0,0 +1,9 @@ +// Docs-site path prefix for a UI language. English docs are prefix-less; +// every other docs locale lives under `/docs//`. +export function docsLocalePrefix(language?: string): string { + if (language?.startsWith("zh")) return "/zh"; + if (language?.startsWith("ja")) return "/ja"; + if (language?.startsWith("ko")) return "/ko"; + if (language?.startsWith("fr")) return "/fr"; + return ""; +} diff --git a/packages/views/common/format-bytes.ts b/packages/views/common/format-bytes.ts new file mode 100644 index 00000000000..46a3b159051 --- /dev/null +++ b/packages/views/common/format-bytes.ts @@ -0,0 +1,6 @@ +/** Compact file size for UI metadata: `412 KB`, `3.2 MB`. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx index 827a2d123cc..a45e56c93db 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.test.tsx @@ -249,17 +249,34 @@ afterEach(() => { }); describe("AgentTranscriptDialog", () => { - it("explains unavailable live events for an empty Antigravity transcript", async () => { + it("opens the matching result and duration for parallel same-tool calls", () => { + const at = (seconds: number) => + new Date(Date.parse(baseTask.started_at!) + seconds * 1000).toISOString(); + renderDialog([ + { seq: 1, type: "tool_use", tool: "Bash", callId: "A", input: { command: "slow-A" }, created_at: at(0) }, + { seq: 2, type: "tool_use", tool: "Bash", callId: "B", input: { command: "fast-B" }, created_at: at(1) }, + { seq: 3, type: "tool_result", tool: "Bash", callId: "B", output: "B finished", created_at: at(3) }, + { seq: 4, type: "tool_result", tool: "Bash", callId: "A", output: "A finished", created_at: at(10) }, + ]); + const slow = screen.getByRole("button", { name: /slow-A/ }); + const fast = screen.getByRole("button", { name: /fast-B/ }); + expect(slow).toHaveTextContent("10s"); + expect(fast).toHaveTextContent("2.0s"); + fireEvent.click(slow); + expect(screen.getByText("A finished", { selector: "pre" })).toBeInTheDocument(); + expect(screen.queryByText("B finished", { selector: "pre" })).not.toBeInTheDocument(); + fireEvent.click(fast); + expect(screen.getByText("B finished", { selector: "pre" })).toBeInTheDocument(); + expect(screen.queryByText("A finished", { selector: "pre" })).not.toBeInTheDocument(); + }); + + it("waits for live events from Antigravity", async () => { vi.mocked(api.listRuntimes).mockResolvedValue([runtimeFor("antigravity")]); renderDialog([], { task: liveTask, isLive: true }); - expect( - await screen.findByText( - "Antigravity does not currently provide live execution events. The transcript will be available after the run completes.", - ), - ).toBeInTheDocument(); - expect(screen.queryByText("Waiting for events...")).not.toBeInTheDocument(); + await screen.findByRole("button", { name: "Run details" }); + expect(screen.getByText("Waiting for events...")).toBeInTheDocument(); }); it("keeps waiting for live events from other runtimes", async () => { @@ -273,6 +290,19 @@ describe("AgentTranscriptDialog", () => { expect(screen.getByText("Waiting for events...")).toBeInTheDocument(); }); + it("shows live Antigravity tool events", async () => { + vi.mocked(api.listRuntimes).mockResolvedValue([runtimeFor("antigravity")]); + + renderDialog([ + { seq: 1, type: "tool_use", tool: "run_command", input: { CommandLine: "echo hello" } }, + { seq: 2, type: "tool_result", tool: "run_command", output: "hello" }, + ], { task: liveTask, isLive: true }); + + await screen.findByRole("button", { name: "Run details" }); + expect(screen.queryByText("Waiting for events...")).not.toBeInTheDocument(); + expect(screen.getAllByText(/run_command/).length).toBeGreaterThan(0); + }); + it("preserves selected filters across dialog remounts unconditionally", () => { const first = renderDialog(); diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index 367bc8950b7..d582897ffd3 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -12,7 +12,6 @@ import { XCircle, X, Loader2, - Clock, Copy, Check, ChevronRight, @@ -102,6 +101,7 @@ import { formatUsd, summarizeTaskUsage, } from "../../runtimes/utils"; +import { formatBytes } from "../format-bytes"; import "../../editor/styles/code.css"; import "./task-transcript.css"; @@ -555,11 +555,6 @@ export function AgentTranscriptDialog({ ? Math.max(0, (timeMs(selectedStep.startedAt) ?? runStartMs) - runStartMs) : undefined; - // Keyed on the run having produced nothing at all, not on the filtered view - // being empty — a filter that hides every step is not a runtime limitation. - const isAntigravityLiveEmpty = - isLive && steps.length === 0 && runtimeInfo?.provider === "antigravity"; - // Newest-first shows live events as PREPENDS, and Virtuoso items opt out of // native scroll anchoring (`overflow-anchor: none`), so without compensation // every 500ms flush shifts the reading position. Virtuoso's contract: a @@ -1220,12 +1215,7 @@ export function AgentTranscriptDialog({
{contentState ?
{contentState}
: displayRows.length === 0 ? (
- {isAntigravityLiveEmpty ? ( -
- - {t(($) => $.transcript.antigravity_live_unavailable)} -
- ) : isLive && steps.length === 0 ? ( + {isLive && steps.length === 0 ? (
{t(($) => $.transcript.waiting_events)} @@ -1853,9 +1843,3 @@ function readPathFromInput(input: Record | undefined): string | const path = input.file_path ?? input.path; return typeof path === "string" ? path : undefined; } - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} diff --git a/packages/views/common/task-transcript/build-steps.test.ts b/packages/views/common/task-transcript/build-steps.test.ts index e586d2efd82..f4377b4dd98 100644 --- a/packages/views/common/task-transcript/build-steps.test.ts +++ b/packages/views/common/task-transcript/build-steps.test.ts @@ -10,7 +10,7 @@ import { toolKindTotals, type TraceCallStep, } from "./build-steps"; -import type { TimelineItem } from "./build-timeline"; +import { buildTimeline, type TimelineItem } from "./build-timeline"; const T0 = "2026-08-15T10:00:00.000Z"; function at(seconds: number): string { @@ -29,6 +29,28 @@ function text(seconds: number, content = "done"): TimelineItem { } describe("buildSteps", () => { + it("pairs parallel same-tool results by call ID after timeline projection", () => { + const messages = [ + { ...call("Bash", 0, { command: "slow-A" }), call_id: "attempt-1:A" }, + { ...call("Bash", 1, { command: "fast-B" }), call_id: "attempt-1:B" }, + { ...result("Bash", 3, "B finished"), call_id: "attempt-1:B" }, + { ...result("Bash", 10, "A finished"), call_id: "attempt-1:A" }, + ].map((item) => ({ ...item, task_id: "task-1", issue_id: "issue-1" })); + // Live snapshots and a fresh history response must use the same pairing. + for (const rows of [messages.slice(0, 3), messages, structuredClone(messages)]) { + const steps = buildSteps(buildTimeline(rows)) as TraceCallStep[]; + expect(steps).toHaveLength(2); + expect(steps[1]!.result?.output).toBe("B finished"); + expect(steps[1]!.durationMs).toBe(2000); + if (rows.length === 4) { + expect(steps[0]!.result?.output).toBe("A finished"); + expect(steps[0]!.durationMs).toBe(10000); + } else { + expect(steps[0]!.result).toBeUndefined(); + } + } + }); + it("folds a call and its result into one step", () => { const steps = buildSteps([call("Bash", 0), result("Bash", 4)]); @@ -57,6 +79,55 @@ describe("buildSteps", () => { expect(steps[1]!.result?.output).toBe("second done"); }); + it("uses identity when a result omits its tool name", () => { + const steps = buildSteps([ + { ...call("Bash", 0), callId: "A" }, + { ...result("", 2), callId: "A" }, + ]) as TraceCallStep[]; + expect(steps).toHaveLength(1); + expect(steps[0]!.tool).toBe("Bash"); + expect(steps[0]!.durationMs).toBe(2000); + }); + + it("keeps unmatched identified results separate from other IDs and legacy calls", () => { + const steps = buildSteps([ + { ...call("Bash", 0), callId: "previous:A" }, + call("Bash", 1), + { ...result("Bash", 2, "orphan"), callId: "retry:A" }, + result("Bash", 3, "legacy"), + { ...result("Bash", 4, "previous"), callId: "previous:A" }, + ]) as TraceCallStep[]; + expect(steps).toHaveLength(3); + expect(steps[0]!.result?.output).toBe("previous"); + expect(steps[1]!.result?.output).toBe("legacy"); + expect(steps[2]!.call).toBeUndefined(); + expect(steps[2]!.result?.output).toBe("orphan"); + expect(steps[2]!.durationMs).toBeUndefined(); + }); + + it("does not attach a result without identity to an identified call", () => { + const steps = buildSteps([ + { ...call("Bash", 0), callId: "A" }, + result("Bash", 1), + ]) as TraceCallStep[]; + expect(steps).toHaveLength(2); + expect(steps[0]!.result).toBeUndefined(); + expect(steps[1]!.call).toBeUndefined(); + }); + + it("keeps duplicate results as orphans once their identified call is closed", () => { + const steps = buildSteps([ + { ...call("Bash", 0), callId: "A" }, + { ...call("Bash", 1), callId: "B" }, + { ...result("Bash", 2), callId: "A" }, + { ...result("Bash", 3), callId: "A" }, + ]) as TraceCallStep[]; + expect(steps).toHaveLength(3); + expect(steps[0]!.durationMs).toBe(2000); + expect(steps[1]!.result).toBeUndefined(); + expect(steps[2]!.call).toBeUndefined(); + }); + it("does not pair across different tools", () => { const steps = buildSteps([call("Read", 0), result("Bash", 1)]) as TraceCallStep[]; @@ -95,6 +166,24 @@ describe("buildSteps", () => { }); describe("groupSteps", () => { + it("spans until the last completion when parallel calls finish out of order", () => { + const items = [ + { ...call("Read", 0), callId: "A" }, + { ...call("Read", 1), callId: "B" }, + { ...call("Read", 2), callId: "C" }, + { ...result("Read", 3), callId: "B" }, + { ...result("Read", 4), callId: "C" }, + { ...result("Read", 10), callId: "A" }, + ]; + const [group] = groupSteps(buildSteps(items)); + expect(group?.kind).toBe("group"); + if (group?.kind !== "group") throw new Error("expected a group"); + expect(group.endedAt).toBe(at(10)); + expect(group.durationMs).toBe(10000); + const [pending] = groupSteps(buildSteps(items.slice(0, -1))); + expect(pending?.kind === "group" && pending.durationMs).toBeUndefined(); + }); + it("folds three or more consecutive same-tool calls", () => { const steps = buildSteps([ call("Read", 0), diff --git a/packages/views/common/task-transcript/build-steps.ts b/packages/views/common/task-transcript/build-steps.ts index 683a18b4ef1..b6f1c831b7e 100644 --- a/packages/views/common/task-transcript/build-steps.ts +++ b/packages/views/common/task-transcript/build-steps.ts @@ -10,10 +10,9 @@ import type { TimelineItem } from "./build-timeline"; * the pair back together, so one call reads as one line and its result is that * line's detail. * - * Pairing is positional because the events carry no call id. `agent.Message` - * has `CallID` all the way to the daemon, but `TaskMessageData` drops it before - * the report and `task_message` has no column for it — until that lands, a - * result belongs to the oldest still-open call with the same tool name. + * Identified events pair by their opaque call ID, regardless of completion + * order or tool name. Only events without identity use the legacy tool-name + * FIFO; mixing the two would attach orphan results to unrelated calls. */ /** One tool call. Either side can be missing: a call still running has no @@ -95,12 +94,12 @@ function durationBetween(start?: string, end?: string): number | undefined { /** Fold `tool_use` / `tool_result` pairs into single steps, in stream order. */ export function buildSteps(items: TimelineItem[]): TraceStep[] { const steps: TraceStep[] = []; - // Open calls per tool, oldest first. FIFO rather than nearest-preceding: - // when a provider runs two calls of the same tool in parallel it returns - // them in call order more often than in reverse. + // Separate ID and tool-name keys so a missing/unmatched ID never consumes + // an identified call through the legacy fallback (or vice versa). const open = new Map(); for (const item of items) { + const pairingKey = item.callId ? `id:${item.callId}` : `tool:${item.tool ?? ""}`; if (item.type === "tool_use") { const tool = item.tool ?? ""; const step: TraceCallStep = { @@ -111,15 +110,15 @@ export function buildSteps(items: TimelineItem[]): TraceStep[] { startedAt: item.created_at, }; steps.push(step); - const queue = open.get(tool); + const queue = open.get(pairingKey); if (queue) queue.push(step); - else open.set(tool, [step]); + else open.set(pairingKey, [step]); continue; } if (item.type === "tool_result") { const tool = item.tool ?? ""; - const pending = open.get(tool)?.shift(); + const pending = open.get(pairingKey)?.shift(); if (pending) { pending.result = item; pending.endedAt = item.created_at; @@ -160,15 +159,21 @@ export function groupSteps(steps: TraceStep[]): TraceRow[] { rows.push(...run); } else { const first = run[0]!; - const last = run[run.length - 1]!; + // Call order no longer implies completion order. Do not display a + // completed span while any member still lacks its end timestamp. + const endedAt = run.every((step) => timeMs(step.endedAt) !== undefined) + ? run.reduce((latest, step) => + timeMs(step.endedAt)! > timeMs(latest)! ? step.endedAt : latest, + first.endedAt) + : undefined; rows.push({ kind: "group", seq: first.seq, tool: first.tool, steps: run, startedAt: first.startedAt, - endedAt: last.endedAt, - durationMs: durationBetween(first.startedAt, last.endedAt), + endedAt, + durationMs: durationBetween(first.startedAt, endedAt), }); } run = []; diff --git a/packages/views/common/task-transcript/build-timeline.ts b/packages/views/common/task-transcript/build-timeline.ts index 8a8085fdb45..9f70158720b 100644 --- a/packages/views/common/task-transcript/build-timeline.ts +++ b/packages/views/common/task-transcript/build-timeline.ts @@ -6,6 +6,8 @@ export interface TimelineItem { seq: number; type: "tool_use" | "tool_result" | "thinking" | "text" | "error"; tool?: string; + /** Opaque identity for pairing tool events within a backend execution. */ + callId?: string; content?: string; input?: Record; output?: string; @@ -112,6 +114,7 @@ function mergeRun(run: readonly TaskMessagePayload[]): TimelineItem { seq: first.seq, type: first.type, tool: first.tool, + callId: first.call_id, content, input: first.input, output: first.output, diff --git a/packages/views/common/task-transcript/run-outcome.test.ts b/packages/views/common/task-transcript/run-outcome.test.ts index 99c8714b2f6..bb0262beee9 100644 --- a/packages/views/common/task-transcript/run-outcome.test.ts +++ b/packages/views/common/task-transcript/run-outcome.test.ts @@ -1,7 +1,8 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; import { buildRunOutcome } from "./run-outcome"; -import { buildSteps } from "./build-steps"; +import { buildSteps, groupSteps, toolKindTotals } from "./build-steps"; +import { traceEventDetail, traceToolArgSummary } from "./trace-event-presenter"; import type { TimelineItem } from "./build-timeline"; let seq = 0; @@ -24,6 +25,76 @@ function read(path: string): TimelineItem { } describe("buildRunOutcome", () => { + it("keeps normalized Antigravity commands visible and counts each command", () => { + const calls: TimelineItem[] = ["pwd", "git status", "go test ./..."].map((command) => ({ + seq: ++seq, + type: "tool_use", + tool: "run_command", + input: { + Cwd: "/workspace", + command: `/bin/sh -c '${command}'`, + }, + })); + const steps = buildSteps(calls); + + expect(groupSteps(steps).map((row) => row.kind)).toEqual(["call", "call", "call"]); + expect(buildRunOutcome(steps)?.commandCount).toBe(3); + expect(calls.map((call) => traceToolArgSummary(call.input))).toEqual([ + "pwd", + "git status", + "go test ./...", + ]); + expect(traceToolArgSummary({ file_path: "/workspace/a.go" })).toBe("/workspace/a.go"); + }); + + it.each([ + { name: "write", tool: "write_to_file", input: { content: "hello" }, kind: "file" }, + { name: "empty file", tool: "write_to_file", input: { content: "" }, kind: "file" }, + { + name: "edit", + tool: "replace_file_content", + input: { old_string: "before", new_string: "after" }, + kind: "diff", + }, + { + name: "deletion", + tool: "replace_file_content", + input: { old_string: "before", new_string: "" }, + kind: "diff", + }, + { + name: "insertion", + tool: "replace_file_content", + input: { old_string: "", new_string: "after" }, + kind: "diff", + }, + ])( + "renders a normalized Antigravity $name and classifies its time as writing", + ({ tool, input, kind }) => { + const call: TimelineItem = { + seq: ++seq, + type: "tool_use", + tool, + input: { file_path: "/workspace/a.go", ...input }, + created_at: "2026-09-23T00:00:00.000Z", + }; + const steps = buildSteps([ + call, + { + seq: ++seq, + type: "tool_result", + tool, + output: "ok", + created_at: "2026-09-23T00:00:01.000Z", + }, + ]); + + expect(traceEventDetail(call)).toMatchObject({ kind, path: "/workspace/a.go" }); + expect(toolKindTotals(steps)).toEqual({ command: 0, write: 1000, read: 0, other: 0 }); + expect(buildRunOutcome(steps)?.paths).toEqual(["/workspace/a.go"]); + }, + ); + it("counts changed lines from an edit", () => { const outcome = buildRunOutcome(buildSteps([edit("a.ts", "one\ntwo", "one\ntwo\nthree")]))!; diff --git a/packages/views/editor/attachment-card.tsx b/packages/views/editor/attachment-card.tsx index 93492360979..4da1a0acd6f 100644 --- a/packages/views/editor/attachment-card.tsx +++ b/packages/views/editor/attachment-card.tsx @@ -11,7 +11,7 @@ import { Download, Eye, FileText, Loader2, Trash2 } from "lucide-react"; import { useT } from "../i18n"; -import { getPreviewKind } from "./utils/preview"; +import { canOpenPreview, getPreviewKind } from "./utils/preview"; interface AttachmentCardChromeProps { filename: string; @@ -135,15 +135,9 @@ export function AttachmentCard({ onDelete, }: AttachmentCardProps) { const kind = filename ? getPreviewKind(contentType, filename) : null; - // Media kinds (pdf/video/audio) are previewable from a URL alone — the - // modal renders them as