mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 16:39:33 +02:00
Compare commits
1 Commits
fix/header
...
agent/matt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a8b37981 |
63
.env.example
63
.env.example
@@ -21,16 +21,11 @@ APP_ENV=
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
PORT=8080
|
||||
# Docker Compose consumes flat port values. Set BACKEND_PORT directly to
|
||||
# override the backend host port.
|
||||
BACKEND_PORT=8080
|
||||
# Optional aliases for local/self-host backend port helpers outside compose.
|
||||
# Optional aliases for the local/self-host backend port. If one is set, it
|
||||
# takes precedence over PORT in compose, Makefile, and installer helpers.
|
||||
# BACKEND_PORT=8080
|
||||
# API_PORT=8080
|
||||
# SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when serving frontend on a different origin/domain.
|
||||
FRONTEND_ORIGIN=http://localhost:${FRONTEND_PORT}
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
@@ -40,9 +35,9 @@ JWT_SECRET=change-me-in-production
|
||||
# Derived by Makefile / local scripts from the backend port.
|
||||
# Set explicitly only when the daemon reaches the API through a different URL.
|
||||
# MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when the app's public URL differs from local frontend.
|
||||
MULTICA_APP_URL=${FRONTEND_ORIGIN}
|
||||
# MULTICA_APP_URL=http://localhost:3000
|
||||
# Public URL the API is reachable at from the open internet (no trailing
|
||||
# slash). Used to mint absolute webhook URLs for autopilot webhook
|
||||
# triggers and to show correct daemon setup commands in the web UI. Leave
|
||||
@@ -100,16 +95,12 @@ RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
# Required by providers that only offer port 465 and do not advertise
|
||||
# STARTTLS (e.g. Aliyun enterprise mail). Auto-enabled when SMTP_PORT=465
|
||||
# and SMTP_TLS is unset.
|
||||
# SMTP_EHLO_NAME is the EHLO/HELO name announced to the relay. Defaults to the
|
||||
# machine hostname; set a real FQDN when a strict relay (e.g. Google Workspace
|
||||
# smtp-relay.gmail.com) rejects the default and the connection drops as an EOF.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=25
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_INSECURE=false
|
||||
SMTP_TLS=
|
||||
SMTP_EHLO_NAME=
|
||||
|
||||
# Google OAuth
|
||||
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
|
||||
@@ -117,9 +108,9 @@ SMTP_EHLO_NAME=
|
||||
# rebuild is needed.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when your OAuth callback URL differs from local frontend.
|
||||
GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
|
||||
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
@@ -127,15 +118,6 @@ GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
|
||||
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
|
||||
S3_BUCKET=
|
||||
S3_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
# AWS_ENDPOINT_URL — optional S3-compatible endpoint (MinIO, RustFS, R2, etc.).
|
||||
# For internal Docker/VPC hosts such as http://rustfs:9000, leave
|
||||
# ATTACHMENT_DOWNLOAD_MODE=auto or set proxy explicitly so browsers/CLI do
|
||||
# not need direct access to the object store.
|
||||
AWS_ENDPOINT_URL=
|
||||
ATTACHMENT_DOWNLOAD_MODE=auto
|
||||
ATTACHMENT_DOWNLOAD_URL_TTL=30m
|
||||
CLOUDFRONT_KEY_PAIR_ID=
|
||||
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
|
||||
CLOUDFRONT_PRIVATE_KEY=
|
||||
@@ -206,35 +188,12 @@ CORS_ALLOWED_ORIGINS=
|
||||
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
|
||||
GITHUB_APP_SLUG=
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
# Optional: GitHub App identity for App-authenticated REST calls. When set,
|
||||
# the setup callback enriches the installation row with the real account
|
||||
# login (org / user name) immediately after install. When unset, the row
|
||||
# is created with the "unknown" placeholder and the next `installation`
|
||||
# webhook from GitHub overwrites it — set both to skip that interim flash.
|
||||
# GITHUB_APP_ID is the numeric "App ID" shown on the App's settings page.
|
||||
# GITHUB_APP_PRIVATE_KEY is the full PEM block (including BEGIN/END lines)
|
||||
# generated under "Private keys" on that same page; preserve newlines.
|
||||
GITHUB_APP_ID=
|
||||
GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# Lark / Feishu bot integration (Settings → Integrations "Bind to Lark")
|
||||
# Off until MULTICA_LARK_SECRET_KEY is set — a base64-encoded 32-byte key
|
||||
# that encrypts each Bot's app secret at rest. Leave empty to disable.
|
||||
# Generate one with: openssl rand -base64 32
|
||||
MULTICA_LARK_SECRET_KEY=
|
||||
# Mainland 飞书 and international Lark are auto-detected per installation
|
||||
# (at QR scan) and served side by side — LEAVE THESE EMPTY for normal use.
|
||||
# They are optional deployment-wide overrides that force EVERY installation
|
||||
# onto one host (a proxy, a mock for tests, or a single-cloud staging
|
||||
# setup); HTTP drives outbound Open Platform API calls, CALLBACK the inbound
|
||||
# long-conn bootstrap. NOTE: if you previously ran international Lark by
|
||||
# setting these to https://open.larksuite.com, the server relabels your
|
||||
# existing installs to region=lark on first boot after upgrade, so you can
|
||||
# clear these afterwards. See docs/lark-bot-integration.
|
||||
MULTICA_LARK_HTTP_BASE_URL=
|
||||
MULTICA_LARK_CALLBACK_BASE_URL=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when serving frontend on a different origin/domain.
|
||||
# FRONTEND_ORIGIN=http://localhost:3000
|
||||
# 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.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -98,7 +98,7 @@ jobs:
|
||||
run: cd server && go run ./cmd/migrate up
|
||||
|
||||
- name: Test
|
||||
run: cd server && go test -race ./...
|
||||
run: cd server && go test ./...
|
||||
|
||||
installer:
|
||||
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
|
||||
|
||||
9
.github/workflows/mobile-verify.yml
vendored
9
.github/workflows/mobile-verify.yml
vendored
@@ -13,9 +13,8 @@ name: Mobile Verify
|
||||
# - pnpm-workspace.yaml — catalog versions
|
||||
# - turbo.json — turbo task pipeline
|
||||
#
|
||||
# Mobile's vitest suite is intentionally narrow (Node env, pure-function
|
||||
# tests under apps/mobile/lib/*.test.ts — see apps/mobile/vitest.config.ts).
|
||||
# RN component-level rendering is not exercised here.
|
||||
# Mobile has no vitest suite today; if one lands, add `test` to the turbo
|
||||
# task list below.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -62,5 +61,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Type check, lint, and test
|
||||
run: pnpm exec turbo typecheck lint test --filter=@multica/mobile
|
||||
- name: Type check and lint
|
||||
run: pnpm exec turbo typecheck lint --filter=@multica/mobile
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && go test -race ./...
|
||||
run: cd server && go test ./...
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
|
||||
32
AGENTS.md
32
AGENTS.md
@@ -3,10 +3,8 @@
|
||||
This file provides guidance to AI agents when working with code in this repository.
|
||||
|
||||
> **Single source of truth:** This file is a concise pointer document.
|
||||
> All authoritative architecture, coding rules, and conventions
|
||||
> All authoritative architecture, coding rules, commands, and conventions
|
||||
> live in **CLAUDE.md** at the project root. Read that file first.
|
||||
> Use `Makefile`, `package.json`, and `pnpm-workspace.yaml` as the
|
||||
> source of truth for the full command list.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -14,27 +12,27 @@ This file provides guidance to AI agents when working with code in this reposito
|
||||
|
||||
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
|
||||
|
||||
- `server/` - Go backend (Chi router, sqlc, gorilla/websocket)
|
||||
- `apps/web/` - Next.js frontend (App Router)
|
||||
- `apps/desktop/` - Electron desktop app
|
||||
- `packages/core/` - Headless business logic (Zustand stores, React Query hooks, API client)
|
||||
- `packages/ui/` - Atomic UI components (shadcn/Base UI, zero business logic)
|
||||
- `packages/views/` - Shared business pages/components
|
||||
- `packages/tsconfig/` - Shared TypeScript config
|
||||
- `server/` — Go backend (Chi router, sqlc, gorilla/websocket)
|
||||
- `apps/web/` — Next.js frontend (App Router)
|
||||
- `apps/desktop/` — Electron desktop app
|
||||
- `packages/core/` — Headless business logic (Zustand stores, React Query hooks, API client)
|
||||
- `packages/ui/` — Atomic UI components (shadcn/Base UI, zero business logic)
|
||||
- `packages/views/` — Shared business pages/components
|
||||
- `packages/tsconfig/` — Shared TypeScript config
|
||||
|
||||
### State Management (critical)
|
||||
|
||||
- **React Query** owns all server state (issues, members, agents, inbox, workspace list)
|
||||
- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals)
|
||||
- All Zustand stores live in `packages/core/` - never in `packages/views/` or app directories
|
||||
- WS events invalidate React Query - never write directly to stores
|
||||
- All Zustand stores live in `packages/core/` — never in `packages/views/` or app directories
|
||||
- WS events invalidate React Query — never write directly to stores
|
||||
|
||||
### Package Boundaries (hard rules)
|
||||
|
||||
- `packages/core/` - zero react-dom, zero localStorage, zero process.env
|
||||
- `packages/ui/` - zero `@multica/core` imports
|
||||
- `packages/views/` - zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
|
||||
- `apps/web/platform/` - only place for Next.js APIs
|
||||
- `packages/core/` — zero react-dom, zero localStorage, zero process.env
|
||||
- `packages/ui/` — zero `@multica/core` imports
|
||||
- `packages/views/` — zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
|
||||
- `apps/web/platform/` — only place for Next.js APIs
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -46,4 +44,4 @@ make test # Go tests
|
||||
make check # Full verification pipeline
|
||||
```
|
||||
|
||||
See CLAUDE.md for the authoritative rules and common commands.
|
||||
See CLAUDE.md for the complete command reference.
|
||||
|
||||
506
CLAUDE.md
506
CLAUDE.md
@@ -1,226 +1,426 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code when working in this repository. Keep this file short and authoritative: rules here should be hard to infer from code or easy to get wrong.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Conventions
|
||||
## Conventions reference
|
||||
|
||||
The source of truth for code naming, i18n glossary, and Chinese product voice is:
|
||||
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
|
||||
|
||||
- `apps/docs/content/docs/developers/conventions.mdx`
|
||||
- `apps/docs/content/docs/developers/conventions.zh.mdx`
|
||||
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
|
||||
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
|
||||
|
||||
Read it before editing translations in `packages/views/locales/`, naming routes/packages/files/DB columns/types, or writing Chinese UI/docs copy. Do not rely on `packages/views/locales/glossary.md`; it is only a redirect stub.
|
||||
Read that page before:
|
||||
|
||||
## Project Shape
|
||||
- Writing or editing translations (`packages/views/locales/`)
|
||||
- Naming a new route, package, file, DB column, or TS type
|
||||
- Writing Chinese product copy (UI strings, error messages, docs)
|
||||
|
||||
Multica is an AI-native task management platform for small teams, with agents as first-class assignees that can own issues, comment, and change status.
|
||||
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
|
||||
|
||||
- `server/`: Go backend, Chi router, sqlc, gorilla/websocket.
|
||||
- `apps/web/`: Next.js App Router.
|
||||
- `apps/desktop/`: Electron desktop app.
|
||||
- `apps/mobile/`: Expo / React Native iOS app. Read `apps/mobile/CLAUDE.md` before touching it.
|
||||
- `packages/core/`: headless business logic, API client, React Query hooks, Zustand stores.
|
||||
- `packages/ui/`: atomic UI components only.
|
||||
- `packages/views/`: shared business pages/components for web and desktop.
|
||||
- `packages/tsconfig/`: shared TypeScript config.
|
||||
## Project Context
|
||||
|
||||
Shared packages export raw `.ts` / `.tsx` and are compiled by consuming apps. Dependency direction is `views -> core + ui`; `core` and `ui` must stay independent.
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
|
||||
## State Rules
|
||||
- Agents can be assigned issues, create issues, comment, and change status
|
||||
- Supports local (daemon) and cloud agent runtimes
|
||||
- Built for 2-10 person AI-native teams
|
||||
|
||||
Keep server state and client state separate.
|
||||
## Architecture
|
||||
|
||||
- TanStack Query owns server state: issues, users, workspaces, inbox, agents, members, and anything fetched from the API.
|
||||
- Zustand owns client state: selected workspace, filters, drafts, modals, tab layout, and navigation history.
|
||||
- Shared Zustand stores live in `packages/core/`, never in `packages/views/` or app directories.
|
||||
- React Context is for platform plumbing only, such as `WorkspaceIdProvider` and `NavigationProvider`.
|
||||
- Only auth/workspace stores may call `api.*` directly. Other server interaction belongs in queries/mutations.
|
||||
- Workspace-scoped query keys must include `wsId`.
|
||||
- Mutations should be optimistic by default: patch locally, send request, roll back on failure, invalidate on settle.
|
||||
- WebSocket events invalidate or patch Query cache; they never write directly to Zustand stores.
|
||||
- Persist durable preferences/drafts/layout. Do not persist server data or ephemeral UI state.
|
||||
- Zustand selectors must return stable references. Do not return freshly allocated objects/arrays from selectors without shallow comparison.
|
||||
- Hooks that need workspace context should accept `wsId`; do not call `useWorkspaceId()` internally unless the hook is guaranteed to run under the provider.
|
||||
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
|
||||
|
||||
## Package Boundaries
|
||||
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||
- `apps/web/` — Next.js frontend (App Router)
|
||||
- `apps/desktop/` — Electron desktop app (electron-vite)
|
||||
- `apps/mobile/` — Expo / React Native iOS app. See `apps/mobile/CLAUDE.md`.
|
||||
- `packages/core/` — Headless business logic (zero react-dom)
|
||||
- `packages/ui/` — Atomic UI components (zero business logic)
|
||||
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
|
||||
- `packages/tsconfig/` — Shared TypeScript configuration
|
||||
|
||||
These are hard constraints:
|
||||
What lives where for sharing purposes is documented in *Sharing Principles* below — read it once.
|
||||
|
||||
- `packages/core/`: no `react-dom`, `localStorage` (use `StorageAdapter`), `process.env`, or UI libraries.
|
||||
- `packages/ui/`: no `@multica/core` imports and no business logic.
|
||||
- `packages/views/`: no `next/*`, no `react-router-dom`, no stores. Use `NavigationAdapter`, `useNavigation()`, and `<AppLink>`.
|
||||
- `apps/web/platform/`: only place for Next.js navigation/platform APIs.
|
||||
- `apps/desktop/src/renderer/src/platform/`: only place for `react-router-dom` navigation wiring.
|
||||
- Every workspace under `apps/` and `packages/` must declare directly imported external packages in its own `package.json`.
|
||||
- Shared dependencies use `catalog:` from `pnpm-workspace.yaml`; `apps/mobile/` pins Expo/React Native related versions directly.
|
||||
### Key Architectural Decisions
|
||||
|
||||
## Sharing Rules
|
||||
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
|
||||
|
||||
Web and desktop share business logic, hooks, stores, components, and views through `packages/core/`, `packages/ui/`, and `packages/views/`.
|
||||
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*`, `react-router-dom`, or app-specific code.
|
||||
|
||||
If the same logic exists in both web and desktop, extract it unless it depends on platform APIs:
|
||||
**Platform bridge:** `packages/core/platform/` provides `CoreProvider` — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider>` and provides its own `NavigationAdapter` for routing.
|
||||
|
||||
1. Next.js, Electron, or router APIs stay in the app/platform layer.
|
||||
2. Headless logic belongs in `packages/core/`.
|
||||
3. Shared UI or business views belong in `packages/views/`.
|
||||
4. Shared primitives belong in `packages/ui/`.
|
||||
**pnpm catalog** — `pnpm-workspace.yaml` defines `catalog:` for version pinning. All shared deps use `catalog:` references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
|
||||
|
||||
Mobile is independent. It may import types and pure functions from `@multica/core`, with `import type` for types, but owns its UI, state, hooks, providers, i18n, React version, build pipeline, and release cadence.
|
||||
### State Management
|
||||
|
||||
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
|
||||
|
||||
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
|
||||
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so they're shared.
|
||||
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
|
||||
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
|
||||
|
||||
**Hard rules — these are how the architecture stays coherent:**
|
||||
|
||||
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
|
||||
- **Workspace-scoped queries must key on `wsId`.** This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed.
|
||||
- **Mutations are optimistic by default.** Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
|
||||
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
|
||||
- **Persist what's worth preserving across restarts** (user preferences, drafts, tab layout). **Don't persist ephemeral UI state** (modal open/close, transient selections) or server data.
|
||||
|
||||
**Common Zustand footguns to avoid:**
|
||||
|
||||
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
|
||||
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
|
||||
|
||||
## Sharing Principles
|
||||
|
||||
The monorepo splits into two share zones:
|
||||
|
||||
- **Web and desktop** share business logic, components, hooks, stores, and views through `packages/core/`, `packages/ui/`, and `packages/views/`. Existing model — keep using it.
|
||||
- **Mobile (`apps/mobile/`) is independent.** It shares only **types and pure functions** from `@multica/core/`, with `import type` for types (zero runtime coupling). UI, state, hooks, providers, i18n, React version, build pipeline, release cadence — all mobile-owned.
|
||||
|
||||
Mobile is locked to the React version that Expo SDK / React Native ships (which lags React main by 6-12 months). Coupling mobile to the root `catalog:` React would block mobile from upgrading on its own schedule.
|
||||
|
||||
See `apps/mobile/CLAUDE.md` for the mobile rules and tech-stack baseline.
|
||||
|
||||
## Commands
|
||||
|
||||
Use the repo scripts as the source of truth. Common commands:
|
||||
|
||||
```bash
|
||||
make dev # auto-setup and start the app
|
||||
make start # start backend + frontend
|
||||
make stop # stop app processes for this checkout
|
||||
make server # run Go server only
|
||||
make daemon # run local daemon
|
||||
make test # Go tests
|
||||
make sqlc # regenerate sqlc code after SQL changes
|
||||
# One-command dev (auto-setup + start everything)
|
||||
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
|
||||
|
||||
# Explicit setup & run (if you prefer separate steps)
|
||||
make setup # First-time: ensure shared DB, create app DB, migrate
|
||||
make start # Start backend + frontend together
|
||||
make stop # Stop app processes for the current checkout
|
||||
make db-down # Stop the shared PostgreSQL container
|
||||
|
||||
# Frontend (all commands go through Turborepo)
|
||||
pnpm install
|
||||
pnpm dev:web
|
||||
pnpm dev:desktop
|
||||
pnpm build
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test # TS/Vitest tests through Turborepo
|
||||
pnpm exec playwright test
|
||||
pnpm ui:add badge # shadcn/Base UI component into packages/ui
|
||||
pnpm dev:web # Next.js dev server (port 3000)
|
||||
pnpm dev:desktop # Electron dev (electron-vite, HMR)
|
||||
pnpm build # Build all frontend apps
|
||||
pnpm typecheck # TypeScript check (all packages + apps via turbo)
|
||||
pnpm lint # ESLint
|
||||
pnpm test # TS tests (Vitest, all packages + apps via turbo)
|
||||
|
||||
# Backend (Go)
|
||||
make server # Run Go server only (port 8080)
|
||||
make daemon # Run local daemon
|
||||
make build # Build server + CLI binaries to server/bin/
|
||||
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
|
||||
make test # Go tests
|
||||
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
|
||||
make migrate-up # Run database migrations
|
||||
make migrate-down # Rollback migrations
|
||||
|
||||
# Run a single TS test (works for any package with a test script)
|
||||
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
|
||||
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
|
||||
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
|
||||
|
||||
# Run a single Go test
|
||||
cd server && go test ./internal/handler/ -run TestName
|
||||
|
||||
# Run a single E2E test (requires backend + frontend running)
|
||||
pnpm exec playwright test e2e/tests/specific-test.spec.ts
|
||||
|
||||
# Mobile (Expo) — two environments only: dev and staging
|
||||
pnpm dev:mobile # Metro, dev env (reads apps/mobile/.env.development.local)
|
||||
pnpm dev:mobile:staging # Metro, staging env (reads apps/mobile/.env.staging)
|
||||
pnpm ios:mobile # Native build + install dev-client to iOS Simulator, dev env
|
||||
pnpm ios:mobile:staging # Native build + install dev-client to iOS Simulator, staging env
|
||||
pnpm ios:mobile:device # Native build + install dev-client to USB iPhone, dev env
|
||||
pnpm ios:mobile:device:staging # Native build + install dev-client to USB iPhone, staging env
|
||||
# Daily flow: run `pnpm dev:mobile:staging` (or :dev). Only re-run `ios:mobile*` when
|
||||
# native code or any expo-*/react-native-* dependency changes (lockfile drift counts).
|
||||
|
||||
# Desktop build & package
|
||||
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
|
||||
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
|
||||
|
||||
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
|
||||
pnpm ui:add badge # Adds component to packages/ui/components/ui/
|
||||
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
make db-down # Stop shared PostgreSQL
|
||||
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
|
||||
```
|
||||
|
||||
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`.
|
||||
### CI Requirements
|
||||
|
||||
CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.
|
||||
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||
|
||||
### Worktree Support
|
||||
|
||||
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
|
||||
|
||||
`make dev` auto-detects worktrees and handles everything. For explicit control:
|
||||
|
||||
```bash
|
||||
make worktree-env # Generate .env.worktree with unique DB/ports
|
||||
make setup-worktree # Setup using .env.worktree
|
||||
make start-worktree # Start using .env.worktree
|
||||
```
|
||||
|
||||
## Coding Rules
|
||||
|
||||
- TypeScript strict mode is enabled; keep types explicit.
|
||||
- Go follows standard conventions: `gofmt`, `go vet`, checked errors.
|
||||
- Code comments must be English.
|
||||
- Prefer existing patterns/components over new parallel abstractions.
|
||||
- Go code follows standard Go conventions (gofmt, go vet).
|
||||
- Keep comments in code **English only**.
|
||||
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
|
||||
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
|
||||
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- For internal, non-boundary code, do not add compatibility layers, fallback paths, dual writes, legacy adapters, or temporary shims unless explicitly requested.
|
||||
- API boundaries are different: installed desktop clients can talk to newer backends, so response parsing must follow the API compatibility rules below.
|
||||
- If a flow or API is being replaced and the product is not live, prefer removing the old path instead of preserving both.
|
||||
- New global pre-workspace routes must be a single word (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Do not add hyphenated root routes like `/new-workspace`.
|
||||
- Reserved slugs live in `server/internal/handler/reserved_slugs.json`. Edit it, run `pnpm generate:reserved-slugs`, and commit the generated `packages/core/paths/reserved-slugs.ts`.
|
||||
- When changing CLI commands/flags, API fields, or product behavior documented by built-in skills under `server/internal/service/builtin_skills/*`, update the relevant `SKILL.md` and `references/*-source-map.md` in the same PR.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
|
||||
|
||||
## API Compatibility
|
||||
### API Response Compatibility
|
||||
|
||||
Frontend code must survive backend response drift, especially in installed desktop builds.
|
||||
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
|
||||
|
||||
- Parse API JSON with `parseWithFallback` in `packages/core/api/schema.ts` and a zod schema. Do not cast network JSON to `T`.
|
||||
- Endpoint responses consumed by UI logic must pass through a schema before returning.
|
||||
- Downstream UI should optional-chain and default fields defensively.
|
||||
- Prefer explicit boolean checks (`=== true`) over truthy/falsy checks on server fields.
|
||||
- Do not pin critical affordances to one backend boolean; combine signals when possible.
|
||||
- Server-driven enum switches need a `default` branch.
|
||||
- When adding or changing an endpoint, add/update the schema and include a malformed-response test.
|
||||
When writing code that consumes an API response, follow these rules:
|
||||
|
||||
## Backend UUID Rules
|
||||
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
|
||||
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
|
||||
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
|
||||
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
|
||||
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
|
||||
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
|
||||
|
||||
In `server/internal/handler/`, always know where a UUID came from before using it in write queries.
|
||||
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
|
||||
|
||||
- Resource path params that may be UUIDs or human-readable IDs must be resolved through loaders such as `loadIssueForUser`, `loadSkillForUser`, `loadAgentForUser`, or `requireDaemonRuntimeAccess`; subsequent writes use the resolved `entity.ID`.
|
||||
- Pure UUID inputs from request boundaries use `parseUUIDOrBadRequest(w, s, fieldName)` and return immediately on `ok=false`.
|
||||
- Trusted UUID round-trips from sqlc results or test fixtures use `parseUUID(s)`, which panics on invalid input.
|
||||
- Outside handlers, `util.ParseUUID(s) (pgtype.UUID, error)` is the safe variant; always check the error.
|
||||
### Backend Handler UUID Parsing Convention
|
||||
|
||||
## Web/Desktop Features
|
||||
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
|
||||
|
||||
When adding a shared page or feature for web and desktop:
|
||||
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
|
||||
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
|
||||
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
|
||||
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
|
||||
|
||||
1. Put the page/component in `packages/views/<domain>/`.
|
||||
2. Add platform wiring in both `apps/web/app/` and the desktop router, unless the desktop flow is a transition overlay.
|
||||
3. Use `useNavigation().push()` or `<AppLink>` in shared code.
|
||||
4. Use shared guards/providers such as `DashboardGuard` from `packages/views/layout/`.
|
||||
5. Keep platform-only UI in the app or inject it through props/slots.
|
||||
6. Hooks that need workspace context should accept `wsId`.
|
||||
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
|
||||
|
||||
CSS for web/desktop is shared from `packages/ui/styles/`. Use semantic tokens such as `bg-background` and `text-muted-foreground`; avoid hardcoded Tailwind colors and duplicated base styles.
|
||||
### Dependency Declaration Rule
|
||||
|
||||
## Desktop Rules
|
||||
Every workspace (`apps/` and `packages/` directories) must explicitly declare all directly imported external packages in its own `package.json`. Relying on pnpm hoist to resolve undeclared imports (phantom deps) is prohibited — it causes production build failures when pnpm creates peer-dep variants.
|
||||
|
||||
Desktop routing has three categories:
|
||||
- Use `"pkg": "catalog:"` to reference the shared version from `pnpm-workspace.yaml`.
|
||||
- CI enforces this via `eslint-plugin-import-x/no-extraneous-dependencies`.
|
||||
- Exception: `apps/mobile/` uses pinned versions (not `catalog:`) for packages tied to its own React/Expo version.
|
||||
|
||||
- Session routes: workspace-scoped tab destinations such as `/:slug/issues`.
|
||||
- Transition flows: pre-workspace one-shot actions such as create workspace or accept invite. These are `WindowOverlay` state, not routes.
|
||||
- Error/stale states: stale workspace tabs should auto-heal by dropping stale tab groups, not render desktop error pages.
|
||||
### Package Boundary Rules
|
||||
|
||||
More desktop constraints:
|
||||
These are hard constraints. Violating them breaks the cross-platform architecture:
|
||||
|
||||
- New pre-workspace desktop flows register a `WindowOverlay` type in `stores/window-overlay-store.ts`; do not add them to `routes.tsx`.
|
||||
- `setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the active workspace source of truth.
|
||||
- Code that leaves workspace context must call `setCurrentWorkspace(null, null)` explicitly.
|
||||
- Leave/delete workspace flow order: read cached destination, clear current workspace, navigate, then run the mutation.
|
||||
- Cross-workspace navigation must go through the navigation adapter so it can call `switchWorkspace(slug, targetPath)`.
|
||||
- Full-window desktop views outside the dashboard shell must mount `<DragStrip />` from `@multica/views/platform` as the first flex child. Interactive controls in the top 48px need `WebkitAppRegion: "no-drag"`.
|
||||
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **Shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
|
||||
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
|
||||
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
|
||||
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
|
||||
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
|
||||
|
||||
## Mobile Rules
|
||||
### The No-Duplication Rule (web + desktop)
|
||||
|
||||
Read `apps/mobile/CLAUDE.md` before touching `apps/mobile/`. It contains the mandatory pre-flight process, import limits, parity rules, tech stack, UI rules, data helpers, realtime strategy, and mobile release flow.
|
||||
**If the same logic exists in both web and desktop, it must be extracted to a shared package.**
|
||||
|
||||
Root-level reminders:
|
||||
This applies to everything between web and desktop: components, hooks, guards, providers, utility functions. The decision process:
|
||||
|
||||
- Mobile shares only `@multica/core` types and pure functions.
|
||||
- Mobile must match web/desktop product semantics: counts, permissions, enums/transitions, and data identity.
|
||||
- Mobile may differ in UI/interaction when the phone context requires it.
|
||||
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
|
||||
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
|
||||
3. Everything else → belongs in `packages/core/` (headless logic) or `packages/views/` (UI components).
|
||||
|
||||
## UI Rules
|
||||
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
|
||||
|
||||
- Prefer shadcn/Base UI components over custom implementations. Add them with `pnpm ui:add <component>` from the repo root.
|
||||
- Use design tokens and semantic classes; avoid hardcoded colors.
|
||||
- Do not introduce extra local state unless the design requires it.
|
||||
- Handle overflow, long text, scrolling, alignment, and spacing deliberately.
|
||||
- If a component is identical between web and desktop, it belongs in a shared package.
|
||||
### Cross-Platform Development Rules (web + desktop)
|
||||
|
||||
## Testing
|
||||
When adding a new page or feature for web/desktop:
|
||||
|
||||
Tests follow the code:
|
||||
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
|
||||
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
|
||||
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
|
||||
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
|
||||
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
|
||||
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
|
||||
|
||||
| What is tested | Location |
|
||||
| --- | --- |
|
||||
| Shared business logic, stores, queries, hooks | `packages/core/*.test.ts` |
|
||||
| Shared UI components, pages, forms, modals | `packages/views/*.test.tsx` |
|
||||
| Platform wiring such as cookies, redirects, search params | `apps/web/*.test.tsx` or `apps/desktop/` |
|
||||
| End-to-end flows | `e2e/*.spec.ts` |
|
||||
| Backend | `server/` Go tests |
|
||||
### CSS Architecture (web + desktop)
|
||||
|
||||
Rules:
|
||||
Web and desktop share the same CSS foundation from `packages/ui/styles/`.
|
||||
|
||||
- Never test shared component behavior in an app test file.
|
||||
- `packages/views/` tests must not mock `next/*` or `react-router-dom`.
|
||||
- Mock `@multica/core` stores with the Zustand callable-store shape (`selectorFn` plus `getState`).
|
||||
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
|
||||
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
|
||||
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
|
||||
|
||||
## Mobile-specific Rules
|
||||
|
||||
Rules for `apps/mobile/` live in `apps/mobile/CLAUDE.md`. Read it before touching anything in `apps/mobile/` — it covers what may be imported from `@multica/core/`, the React version policy, the build/release pipeline, and the locked tech-stack baseline.
|
||||
|
||||
## Desktop-specific Rules
|
||||
|
||||
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
|
||||
|
||||
### Route categories
|
||||
|
||||
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
|
||||
|
||||
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
|
||||
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
|
||||
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
|
||||
|
||||
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
|
||||
|
||||
### Workspace context
|
||||
|
||||
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
|
||||
|
||||
### Workspace destructive operations
|
||||
|
||||
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
|
||||
|
||||
1. Read destination from cached workspace list.
|
||||
2. `setCurrentWorkspace(null, null)`.
|
||||
3. `navigation.push(destination)`.
|
||||
4. THEN `await mutation.mutateAsync(workspaceId)`.
|
||||
|
||||
### Tab isolation
|
||||
|
||||
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
|
||||
|
||||
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
|
||||
|
||||
### Drag region (macOS)
|
||||
|
||||
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
|
||||
- Use shadcn design tokens for styling. Avoid hardcoded color values.
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
|
||||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
### Where to write tests
|
||||
|
||||
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
|
||||
|
||||
| What you're testing | Where the test lives | Why |
|
||||
|---|---|---|
|
||||
| Shared business logic (stores, queries, hooks) | `packages/core/*.test.ts` | No DOM needed, pure logic |
|
||||
| Shared UI components (pages, forms, modals) | `packages/views/*.test.tsx` | jsdom, no framework mocks |
|
||||
| Platform-specific wiring (cookies, redirects, searchParams) | `apps/web/*.test.tsx` or `apps/desktop/` | Needs framework-specific mocks |
|
||||
| End-to-end user flows | `e2e/*.spec.ts` | Real browser, real backend |
|
||||
|
||||
**Never test shared component behavior in an app's test file.** If a test requires mocking `next/navigation` or `react-router-dom` to test a component from `@multica/views`, the test is in the wrong place — move it to `packages/views/` and mock `@multica/core` instead.
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- `packages/core/` — Vitest, Node environment (no DOM)
|
||||
- `packages/views/` — Vitest, jsdom environment, `@testing-library/react`
|
||||
- `apps/web/` — Vitest, jsdom environment, framework-specific mocks
|
||||
- `e2e/` — Playwright
|
||||
- `server/` — Go standard `go test`
|
||||
|
||||
All test deps are in the pnpm catalog for unified versioning.
|
||||
|
||||
### Mocking conventions
|
||||
|
||||
- Mock `@multica/core` stores with `vi.hoisted()` + `Object.assign(selectorFn, { getState })` pattern (Zustand stores are both callable and have `.getState()`).
|
||||
- Mock `@multica/core/api` for API calls.
|
||||
- E2E tests should use `TestApiClient` for setup/teardown.
|
||||
- Prefer writing the failing test in the correct package before implementation when the change is behavioral.
|
||||
- In `packages/views/` tests: never mock `next/*` or `react-router-dom` — those don't exist here.
|
||||
- In `apps/web/` tests: mock framework-specific APIs only for platform-specific behavior.
|
||||
|
||||
## Verification
|
||||
### TDD workflow
|
||||
|
||||
For code changes, run the narrowest useful checks while iterating, then run broader verification when risk justifies it or when asked.
|
||||
1. Write failing test in the **correct package** first.
|
||||
2. Write implementation.
|
||||
3. Run `pnpm test` (Turborepo discovers all packages).
|
||||
4. Green → done.
|
||||
|
||||
Useful checks:
|
||||
### Go tests
|
||||
|
||||
Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
|
||||
### E2E tests
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
|
||||
```typescript
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
let api: TestApiClient;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi();
|
||||
await loginAsDefault(page);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
});
|
||||
|
||||
test("example", async ({ page }) => {
|
||||
const issue = await api.createIssue("Test Issue");
|
||||
await page.goto(`/issues/${issue.id}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Commit Rules
|
||||
|
||||
- Use atomic commits grouped by logical intent.
|
||||
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
|
||||
|
||||
## Minimum Pre-Push Checks
|
||||
|
||||
```bash
|
||||
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
|
||||
```
|
||||
|
||||
Run verification only when the user explicitly asks for it.
|
||||
|
||||
For targeted checks when requested:
|
||||
```bash
|
||||
pnpm typecheck # TypeScript type errors only
|
||||
pnpm test # TS unit tests only (Vitest, all packages)
|
||||
make test # Go tests only
|
||||
pnpm exec playwright test # E2E only (requires backend + frontend running)
|
||||
```
|
||||
|
||||
## AI Agent Verification Loop
|
||||
|
||||
After writing or modifying code, always run the full verification pipeline:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
make test
|
||||
pnpm exec playwright test
|
||||
make check
|
||||
```
|
||||
|
||||
Do not claim verification passed unless you ran it. If you skip checks because the change is docs-only or the user asked not to run them, say so.
|
||||
**Workflow:**
|
||||
- Write code to satisfy the requirement
|
||||
- Run `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run
|
||||
- Repeat until all checks pass
|
||||
- Only then consider the task complete
|
||||
|
||||
## Commits and Releases
|
||||
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
|
||||
|
||||
- Commits should be atomic and use conventional prefixes: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
|
||||
- A production deployment requires a CLI release tag on `main`: create `v0.x.x`, push it, and let `release.yml` publish binaries and the Homebrew tap.
|
||||
- Bump patch by default unless the user specifies a version.
|
||||
## CLI Release
|
||||
|
||||
## Domain Reminders
|
||||
**Prerequisite:** A CLI release must accompany every Production deployment.
|
||||
|
||||
- All queries filter by `workspace_id`; membership gates access; `X-Workspace-ID` selects the workspace.
|
||||
- Issue assignees are polymorphic: `assignee_type` plus `assignee_id` can reference a member or an agent.
|
||||
1. Create a tag on the `main` branch: `git tag v0.x.x`
|
||||
2. Push the tag: `git push origin v0.x.x`
|
||||
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
|
||||
|
||||
By default, bump the patch version each release (e.g. `v0.1.12` → `v0.1.13`), unless the user specifies a specific version.
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
## Agent Assignees
|
||||
|
||||
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||
|
||||
@@ -149,7 +149,6 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
|
||||
| Kimi | `kimi` | Moonshot coding agent |
|
||||
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
|
||||
| [Qoder CLI](https://docs.qoder.com/) | `qodercli` | Qoder ACP coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -169,7 +168,7 @@ Daemon behavior is configured via flags or environment variables:
|
||||
|---------|------|--------------|---------|
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `0` (no cap; bounded by the watchdogs) |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
@@ -221,10 +220,6 @@ Agent-specific overrides:
|
||||
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
| `MULTICA_QODER_PATH` | Custom path to the `qodercli` binary |
|
||||
| `MULTICA_QODER_MODEL` | Override the Qoder model used |
|
||||
|
||||
The daemon launches Qoder as `qodercli --yolo --acp`, matching Qoder’s ACP “bypass permissions” mode so tool runs do not block on interactive approval in headless runs.
|
||||
|
||||
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
|
||||
|
||||
@@ -409,12 +404,12 @@ multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
|
||||
# Most recently active threads (root + every descendant), grouped by
|
||||
# thread. Returns N complete conversational arcs, oldest-active first so
|
||||
# the freshest thread sits closest to "now" in an agent prompt.
|
||||
multica issue comment list <issue-id> --recent 10
|
||||
multica issue comment list <issue-id> --recent 20
|
||||
|
||||
# Scroll older threads. Under --recent, --before / --before-id are a
|
||||
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
|
||||
# `Next thread cursor: --before <ts> --before-id <root-id>`.
|
||||
multica issue comment list <issue-id> --recent 10 \
|
||||
multica issue comment list <issue-id> --recent 20 \
|
||||
--before <ts> --before-id <root-id>
|
||||
|
||||
# Incremental polling. Combines with --thread or --recent; filters out
|
||||
@@ -519,14 +514,8 @@ multica issue run-messages <task-id> --output json
|
||||
|
||||
# Incremental fetch (only messages after a given sequence number)
|
||||
multica issue run-messages <task-id> --since 42 --output json
|
||||
|
||||
# Aggregated token usage for an issue (sum across all its task runs)
|
||||
multica issue usage <issue-id>
|
||||
multica issue usage <issue-id> --output json
|
||||
```
|
||||
|
||||
The `usage` command returns the aggregated token usage for an issue, summed across all of its task runs: input tokens, output tokens, cache read/write tokens, and the run count (`task_count`). It wraps `GET /api/issues/<id>/usage` — the same figures the issue detail view shows. Use `--output json` to feed billing/cost tooling.
|
||||
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. Table output uses short task UUID prefixes by default; pass `--full-id` to print canonical task UUIDs. The `run-messages` command accepts full task UUIDs directly; copied short task prefixes must be scoped with `--issue <issue-id>` so the CLI only checks that issue's runs. It shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
|
||||
## Projects
|
||||
@@ -659,18 +648,14 @@ multica autopilot create \
|
||||
--title "Nightly bug triage" \
|
||||
--description "Scan todo issues and prioritize." \
|
||||
--agent "Lambda" \
|
||||
--mode create_issue \
|
||||
--subscriber "Alice"
|
||||
--mode create_issue
|
||||
|
||||
multica autopilot update <id> --status paused
|
||||
multica autopilot update <id> --description "New prompt"
|
||||
multica autopilot update <id> --subscriber "Alice" --subscriber "Bob"
|
||||
multica autopilot update <id> --clear-subscribers
|
||||
multica autopilot delete <id>
|
||||
```
|
||||
|
||||
`--mode` accepts `create_issue` (creates a new issue on each run and assigns it to the agent) or `run_only` (enqueues a direct agent task without creating an issue). `--agent` accepts either a name or UUID.
|
||||
`--subscriber` accepts a workspace member name or user ID and may be repeated; on update it replaces the autopilot's subscriber template. Subscribers receive inbox notifications for issues created by a `create_issue` autopilot. Use `--clear-subscribers` to remove all autopilot subscribers.
|
||||
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
@@ -714,79 +699,3 @@ Most commands support `--output` with two formats:
|
||||
multica issue list --output json
|
||||
multica daemon status --output json
|
||||
```
|
||||
|
||||
## Error Messages
|
||||
|
||||
The CLI funnels command errors returned to the top-level handler through a
|
||||
single user-facing translation layer (`server/internal/cli/errors.go`) so that
|
||||
what you see on the terminal is a short, actionable sentence rather than a raw
|
||||
Go error, an HTTP status line, or an internal `resolve issue: ...` chain. (A
|
||||
few commands print their own output or run deliberate fast probes — for example
|
||||
`setup`'s short `/health` reachability check — and don't go through this
|
||||
layer.) The underlying detail is still available on demand (see `--debug`).
|
||||
|
||||
### What you see
|
||||
|
||||
- **Friendly, single-line message.** Transport failures (timeout, DNS,
|
||||
connection refused, TLS) and HTTP status failures (401/403/404/409/400·422/
|
||||
429/5xx) are each rendered as one clear sentence with a next step — for
|
||||
example a timeout suggests checking the network or raising
|
||||
`MULTICA_HTTP_TIMEOUT`, and a 401 tells you to run `multica login`.
|
||||
- **Server-provided validation messages are preserved.** For a 400/422 that
|
||||
carries a message from the server, that message is shown verbatim
|
||||
(`Invalid request: <server message>`); only when there is none do you get the
|
||||
generic "check your values / run with --help" hint.
|
||||
- **No leaked internals by default.** Raw URLs, status lines, JSON bodies, and
|
||||
the internal verb chain are hidden unless you ask for them.
|
||||
|
||||
### Language
|
||||
|
||||
Messages default to **English**, matching the rest of the CLI's help output.
|
||||
If a Chinese locale is detected in `LC_ALL`, `LC_MESSAGES`, or `LANG` (in that
|
||||
precedence order), messages switch to **Chinese**. No flag is needed; set the
|
||||
locale as usual:
|
||||
|
||||
```bash
|
||||
LANG=zh_CN.UTF-8 multica issue get MUL-9999 # 错误信息显示为中文
|
||||
```
|
||||
|
||||
### Exit codes
|
||||
|
||||
The process exit code is tiered so scripts can branch on the failure class:
|
||||
|
||||
| Exit code | Meaning |
|
||||
| --- | --- |
|
||||
| `0` | success |
|
||||
| `1` | generic / unclassified error |
|
||||
| `2` | network error (timeout, DNS, connection refused, TLS, offline) |
|
||||
| `3` | authentication / authorization (HTTP 401, 403) |
|
||||
| `4` | not found (HTTP 404) |
|
||||
| `5` | validation (HTTP 400, 422) |
|
||||
|
||||
```bash
|
||||
multica issue get MUL-9999
|
||||
if [ $? -eq 4 ]; then echo "no such issue"; fi
|
||||
```
|
||||
|
||||
### Seeing the full detail (`--debug`)
|
||||
|
||||
Pass the global `--debug` flag (or set `MULTICA_DEBUG=1`) to print the complete
|
||||
original error chain — the internal verb chain, the request method/path/status,
|
||||
and the raw server body — underneath the friendly message. Use it when you need
|
||||
to file a bug or understand exactly what the server returned:
|
||||
|
||||
```bash
|
||||
multica issue list --debug
|
||||
MULTICA_DEBUG=1 multica issue update MUL-1234 --title "x"
|
||||
```
|
||||
|
||||
### Request timeout
|
||||
|
||||
API requests use a default timeout of 30 seconds. Override it with
|
||||
`MULTICA_HTTP_TIMEOUT` when you are on a slow network; it accepts a Go duration
|
||||
(`45s`, `2m`) or a plain number of seconds (`45`). Command-level deadlines are
|
||||
always at least this value, so raising it takes effect across all commands.
|
||||
|
||||
```bash
|
||||
MULTICA_HTTP_TIMEOUT=60s multica issue list
|
||||
```
|
||||
|
||||
@@ -15,12 +15,10 @@ COPY server/ ./server/
|
||||
# Build binaries
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
ARG DATE=unknown
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_codex_usage_cache ./cmd/backfill_codex_usage_cache
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM alpine:3.21
|
||||
@@ -33,7 +31,6 @@ COPY --from=builder /src/server/bin/server .
|
||||
COPY --from=builder /src/server/bin/multica .
|
||||
COPY --from=builder /src/server/bin/migrate .
|
||||
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
|
||||
COPY --from=builder /src/server/bin/backfill_codex_usage_cache .
|
||||
COPY server/migrations/ ./migrations/
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
|
||||
|
||||
23
Makefile
23
Makefile
@@ -58,17 +58,12 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
JWT=$$(openssl rand -hex 32); \
|
||||
PGPASS=$$(openssl rand -hex 24); \
|
||||
if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
|
||||
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
|
||||
else \
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
|
||||
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Pulling official Multica images..."
|
||||
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
|
||||
@@ -113,17 +108,12 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
JWT=$$(openssl rand -hex 32); \
|
||||
PGPASS=$$(openssl rand -hex 24); \
|
||||
if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
|
||||
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
|
||||
else \
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
|
||||
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Building Multica from the current checkout..."
|
||||
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
|
||||
@@ -296,7 +286,7 @@ test: ## Run Go tests after ensuring the target DB exists and migrations are app
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
cd server && go test -race ./...
|
||||
cd server && go test ./...
|
||||
|
||||
# Database
|
||||
##@ Database
|
||||
@@ -317,10 +307,5 @@ sqlc: ## Regenerate sqlc code
|
||||
# Cleanup
|
||||
##@ Cleanup
|
||||
|
||||
clean: ## Remove build caches, generated binaries, and temp files
|
||||
clean: ## Remove generated server binaries and temp files
|
||||
rm -rf server/bin server/tmp
|
||||
rm -rf apps/*/.next apps/*/.source apps/*/.expo
|
||||
rm -rf apps/*/out apps/*/dist apps/*/dist-electron packages/*/dist
|
||||
rm -rf .turbo apps/*/.turbo packages/*/.turbo
|
||||
rm -rf apps/*/*.tsbuildinfo packages/*/*.tsbuildinfo
|
||||
@echo "✓ Clean complete."
|
||||
|
||||
13
README.md
13
README.md
@@ -19,9 +19,8 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
[](https://discord.gg/W8gYBn226t)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [Discord](https://discord.gg/W8gYBn226t) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
@@ -31,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, **Kiro CLI**, and **Qoder CLI**.
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
|
||||
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
|
||||
|
||||
@@ -115,7 +114,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`, `qodercli`) on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -125,7 +124,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, Antigravity, or Qoder CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Antigravity). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
@@ -166,7 +165,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
│ Agent Daemon │ runs on your machine
|
||||
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
|
||||
OpenCode, OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent, Kimi, Kiro CLI, Qoder CLI)
|
||||
Pi, Cursor Agent, Kimi, Kiro CLI)
|
||||
```
|
||||
|
||||
| Layer | Stack |
|
||||
@@ -174,7 +173,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
| Frontend | Next.js 16 (App Router) |
|
||||
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| Database | PostgreSQL 17 with pgvector |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Qoder CLI |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
[](https://discord.gg/W8gYBn226t)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai) · [Discord](https://discord.gg/W8gYBn226t) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
@@ -31,7 +30,7 @@
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi**、**Kiro CLI** 与 **Qoder CLI**。
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi** 和 **Kiro CLI**。
|
||||
|
||||
面向更大的团队,Squads(小队)提供稳定的路由层:把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
|
||||
|
||||
@@ -116,7 +115,7 @@ multica setup # 连接 Multica Cloud,登录,启动 daemon
|
||||
multica setup # 配置、认证、启动 daemon(一条命令搞定)
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`copilot`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`、`kimi`、`kiro-cli`、`qodercli`)。
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`copilot`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`、`kimi`、`kiro-cli`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
@@ -126,7 +125,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi、Kiro CLI 或 Qoder CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
@@ -148,7 +147,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
│ Agent Daemon │ 运行在你的机器上
|
||||
└──────────────┘ (Claude Code、Codex、GitHub Copilot CLI、
|
||||
OpenCode、OpenClaw、Hermes、Gemini、
|
||||
Pi、Cursor Agent、Kimi、Kiro CLI、Qoder CLI)
|
||||
Pi、Cursor Agent、Kimi、Kiro CLI)
|
||||
```
|
||||
|
||||
| 层级 | 技术栈 |
|
||||
@@ -156,7 +155,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
| 前端 | Next.js 16 (App Router) |
|
||||
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| 数据库 | PostgreSQL 17 with pgvector |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi、Kiro CLI 或 Qoder CLI |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI |
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -101,7 +101,6 @@ You also need at least one AI agent CLI installed:
|
||||
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
|
||||
- Kimi (`kimi` on PATH)
|
||||
- Kiro CLI (`kiro-cli` on PATH)
|
||||
- Qoder CLI (`qodercli` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
@@ -145,7 +144,7 @@ If you already run a Kubernetes cluster, you can deploy Multica there instead of
|
||||
The chart creates the following resources in the target namespace:
|
||||
|
||||
- `multica-postgres` — `pgvector/pgvector:pg17` backed by a 10Gi PVC
|
||||
- `multica-backend` — Go API/WS server. Backed by a 5Gi `ReadWriteOnce` uploads PVC by default; set `backend.uploads.persistence.enabled=false` when you have configured S3 (`backend.config.s3Bucket`) and don't want the chart to declare the PVC at all.
|
||||
- `multica-backend` — Go API/WS server backed by a 5Gi uploads PVC
|
||||
- `multica-frontend` — Next.js standalone server
|
||||
- Two `Ingress` resources: one for the web host, one for the backend host
|
||||
- `multica-config` ConfigMap (rendered from `values.yaml`)
|
||||
@@ -327,7 +326,7 @@ To roll back if an upgrade goes sideways:
|
||||
helm -n multica rollback multica
|
||||
```
|
||||
|
||||
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** As of MUL-2957 the `migrate up` command runs an idempotent monthly-slice backfill automatically before applying migration `103`, so a clean upgrade is a single `helm upgrade` + backend rollout. If you are still on a pre-MUL-2957 binary or the auto-hook fails, run the standalone backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations. See [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup) for the full recovery flow.
|
||||
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** Same migration guard as the Docker path — see [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule). Run the backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations.
|
||||
|
||||
### Tearing down
|
||||
|
||||
@@ -341,52 +340,56 @@ kubectl delete namespace multica
|
||||
|
||||
---
|
||||
|
||||
## Usage Dashboard Rollup
|
||||
## Usage Dashboard Rollup (Required)
|
||||
|
||||
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. As of MUL-2957 the backend runs this rollup **in-process** on every replica via a DB-backed scheduler (`sys_cron_executions`); a fresh self-host install needs no operator action and the bundled `pgvector/pgvector:pg17` image works without changes — you do **not** need to swap it for an image that ships `pg_cron`, register an external cron job, set up a systemd timer, or run a Kubernetes `CronJob`.
|
||||
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table rather than directly from `task_usage`. Raw `task_usage` rows are written by the backend on every task, but the dashboard only sees data after `rollup_task_usage_hourly()` runs and aggregates them into `task_usage_hourly`.
|
||||
|
||||
Multiple backend replicas are safe: each replica ticks every 30 seconds and tries to claim the current 5-minute UTC plan, but the unique key `(job_name, scope_kind, scope_id, plan_time)` means only one wins each plan. Inspect steady-state operation:
|
||||
**The bundled `pgvector/pgvector:pg17` image does NOT include `pg_cron`.** If nothing schedules the rollup, the dashboard will stay at zero forever even though `task_usage` is populated. You have three supported options — pick one before relying on the dashboard.
|
||||
|
||||
```sql
|
||||
SELECT plan_time, status, attempt, runner_id,
|
||||
error_code, error_msg, started_at, finished_at
|
||||
FROM sys_cron_executions
|
||||
WHERE job_name = 'rollup_task_usage_hourly'
|
||||
ORDER BY plan_time DESC
|
||||
LIMIT 20;
|
||||
> **Upgrading from `v0.3.4` to `v0.3.5+`** with existing `task_usage` history: migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: …`. Run `backfill_task_usage_hourly` first (Option C below), then re-run the upgrade. **Fresh installs** are exempted by that guard and migrate cleanly — but the dashboard will still stay at zero until you pick Option A or Option B.
|
||||
|
||||
### Option A — External cron / systemd-timer (simplest)
|
||||
|
||||
Schedule a 5-minute job that calls `rollup_task_usage_hourly()`. It is idempotent and watermark-driven, so a missed tick catches up on the next run.
|
||||
|
||||
```bash
|
||||
# /etc/cron.d/multica-rollup — every 5 minutes
|
||||
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
|
||||
exec -T postgres psql -U multica -d multica \
|
||||
-c "SELECT rollup_task_usage_hourly();" >/dev/null
|
||||
```
|
||||
|
||||
Full reference (audit table semantics, advisory lock 4246, the standalone backfill command, flag descriptions, the `v0.3.4 → v0.3.5+` migration auto-hook) lives in [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
|
||||
Or as a systemd timer + service if you prefer that surface. The function returns the number of (upserted + deleted-empty) rows; it's safe to call concurrently with itself (an advisory lock makes overlapping runs no-op) and safe to call alongside `backfill_task_usage_hourly`.
|
||||
|
||||
> **Upgrading from `v0.3.4` to `v0.3.5+`?** As of MUL-2957 the `migrate up` command runs an idempotent monthly-slice backfill automatically right before applying migration `103`, so the upgrade completes in a single invocation — no operator step required. If you are still on a pre-MUL-2957 binary or the auto-hook fails for an environmental reason, run `backfill_task_usage_hourly` against the same database and re-run the upgrade. See [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup) for the recovery flow.
|
||||
### Option B — Swap Postgres for an image that ships `pg_cron`
|
||||
|
||||
### Compatibility paths (existing deployments only)
|
||||
If you'd rather have Postgres schedule itself, replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that bundles both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or your own build of `pgvector/pgvector` with `pg_cron` added and `shared_preload_libraries=pg_cron` set on the server). Then, once:
|
||||
|
||||
External schedulers — **`pg_cron` registered on the database, an external cron job, a systemd timer, or a Kubernetes `CronJob`** — that call `SELECT rollup_task_usage_hourly()` directly were the only option before MUL-2957 and remain a supported compatibility path. They are no longer the recommended setup; new deployments should rely on the in-process scheduler instead. The SQL function holds advisory lock 4246 internally, so the in-process scheduler and any pre-existing external schedule can coexist without ever double-writing the rollup.
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
SELECT cron.schedule(
|
||||
'rollup_task_usage_hourly',
|
||||
'*/5 * * * *',
|
||||
$$SELECT rollup_task_usage_hourly()$$
|
||||
);
|
||||
```
|
||||
|
||||
If you already have a `pg_cron` job in production, the safe sequence to retire it is:
|
||||
`shared_preload_libraries` requires a Postgres restart to take effect — set it in `postgresql.conf` (or via the image's documented mechanism) before bringing the container up.
|
||||
|
||||
1. Confirm the in-process scheduler is healthy on at least one backend replica — recent SUCCESS rows should be landing in `sys_cron_executions` for `rollup_task_usage_hourly`:
|
||||
### Option C — Backfill history first, then schedule
|
||||
|
||||
```sql
|
||||
SELECT plan_time, status, runner_id, finished_at
|
||||
FROM sys_cron_executions
|
||||
WHERE job_name = 'rollup_task_usage_hourly'
|
||||
AND status = 'SUCCESS'
|
||||
ORDER BY plan_time DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
If you're upgrading from `v0.3.4 → v0.3.5+` and already have `task_usage` rows (or you just want the dashboard to show historical data on a fresh install that you've been running for a while), run the bundled backfill command once before scheduling the rollup:
|
||||
|
||||
2. Once SUCCESS rows are arriving on schedule, unschedule the redundant `pg_cron` entry:
|
||||
```bash
|
||||
# Backfills task_usage_hourly from all historical task_usage rows and stamps
|
||||
# the rollup watermark. Idempotent — safe to re-run.
|
||||
docker compose -f docker-compose.selfhost.yml exec backend \
|
||||
./backfill_task_usage_hourly --sleep-between-slices=2s
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT cron.unschedule('rollup_task_usage_hourly')
|
||||
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
|
||||
```
|
||||
On a database with years of data this can scan tens of millions of rows; `--sleep-between-slices=2s` throttles the read pressure. Use `--months-back N` (plus `--force-partial`) if you only want the last N months. Once it finishes, set up Option A or Option B so new buckets keep flowing.
|
||||
|
||||
3. Leave the `pg_cron` extension itself installed unless you are sure no other workload depends on it. The bundled `pgvector/pgvector:pg17` image does **not** ship `pg_cron`, so nothing in Multica's default install needs it; uninstalling `pg_cron` from a custom image that other workloads still use is a separate decision.
|
||||
|
||||
External cron / systemd timer / Kubernetes `CronJob` setups that call `SELECT rollup_task_usage_hourly()` directly can be retired the same way — once `sys_cron_executions` shows steady SUCCESS rows from the in-process scheduler, the external job is redundant and can be removed.
|
||||
After upgrading, re-run `migrate up` (or restart the backend container — migrations run automatically on startup) to apply migration `103` cleanly.
|
||||
|
||||
## Stopping Services
|
||||
|
||||
@@ -428,7 +431,7 @@ docker compose -f docker-compose.selfhost.yml up -d
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
|
||||
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
|
||||
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. As of MUL-2957 `migrate up` runs that backfill automatically right before applying `103`, so the upgrade completes in a single invocation. If you are still on a pre-MUL-2957 binary or the auto-hook fails, run `backfill_task_usage_hourly` manually first, then re-run the upgrade. Full instructions in [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
|
||||
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. Run `backfill_task_usage_hourly` first, then re-run the upgrade. Full instructions in [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ Use this option when your deployment cannot reach the public internet or you alr
|
||||
| `SMTP_PASSWORD` | SMTP password | - |
|
||||
| `SMTP_TLS` | TLS mode. `implicit` (aliases `smtps`, `ssl`) forces SMTPS on connect; port `465` auto-enables it. Unset / `starttls` upgrades via STARTTLS | `starttls` |
|
||||
| `SMTP_TLS_INSECURE` | Set `true` to skip TLS certificate verification (self-signed / private CA certs) | `false` |
|
||||
| `SMTP_EHLO_NAME` | EHLO/HELO name announced to the relay. Set a real FQDN when a strict relay (e.g. Google Workspace) rejects the default greeting from a public IP | machine hostname |
|
||||
|
||||
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is supported and auto-enables implicit TLS; set `SMTP_TLS=implicit` (aliases `smtps`, `ssl`) to force it on a non-standard port.
|
||||
|
||||
@@ -94,8 +93,6 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
|
||||
| `ATTACHMENT_DOWNLOAD_MODE` | Attachment download behavior: `auto` (default), `cloudfront`, `presign`, or `proxy`. Use `proxy` for private buckets behind Docker/VPC-only endpoints such as `http://rustfs:9000` |
|
||||
| `ATTACHMENT_DOWNLOAD_URL_TTL` | TTL for CloudFront signed URLs and S3 presigned download URLs (default: `30m`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
@@ -115,7 +112,7 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins. Governs **both** the HTTP CORS allowlist **and** the WebSocket `Origin` check. A browser origin that isn't listed here (and isn't `localhost`) has its real-time WebSocket upgrade rejected with `403`, so live updates stop working until a manual refresh. |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
|
||||
### CLI / Daemon
|
||||
@@ -184,35 +181,74 @@ cd server && go run ./cmd/migrate up
|
||||
|
||||
## Usage Dashboard Rollup
|
||||
|
||||
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. As of MUL-2957 the backend runs this rollup **in-process** on every replica via a DB-backed scheduler (`sys_cron_executions`); a fresh self-host install needs no operator action — the bundled `pgvector/pgvector:pg17` image works without changes.
|
||||
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. The function is **not** scheduled out of the box on the default self-host stack: the bundled `pgvector/pgvector:pg17` image ships without `pg_cron`, and the backend does not run the rollup in-process either. Until something calls it on a schedule, raw `task_usage` rows will keep arriving while the dashboard stays at zero.
|
||||
|
||||
### How the in-process scheduler works
|
||||
Pick one of the supported paths:
|
||||
|
||||
Every backend replica ticks every 30 seconds and tries to claim the current 5-minute UTC plan in `sys_cron_executions`. The unique key `(job_name, scope_kind, scope_id, plan_time)` makes the claim a single-winner contest across all replicas, so multi-instance deployments do not double-write. The handler then calls `SELECT rollup_task_usage_hourly()`; the SQL function holds advisory lock `4246` internally, so a stray `pg_cron` job or manual call can run alongside the scheduler without ever colliding on the rollup itself. Inspect the audit table for steady-state operation:
|
||||
### Option A — External cron / systemd-timer
|
||||
|
||||
```sql
|
||||
SELECT plan_time, status, attempt, runner_id,
|
||||
error_code, error_msg, started_at, finished_at
|
||||
FROM sys_cron_executions
|
||||
WHERE job_name = 'rollup_task_usage_hourly'
|
||||
ORDER BY plan_time DESC
|
||||
LIMIT 20;
|
||||
The simplest path. Schedule `SELECT rollup_task_usage_hourly()` every five minutes from any out-of-band timer (host crontab, systemd timer, sidecar container, Kubernetes CronJob). It is idempotent and watermark-driven — overlapping runs are no-ops on an internal advisory lock, and a missed tick catches up on the next run.
|
||||
|
||||
Docker Compose:
|
||||
|
||||
```bash
|
||||
# /etc/cron.d/multica-rollup
|
||||
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
|
||||
exec -T postgres psql -U multica -d multica \
|
||||
-c "SELECT rollup_task_usage_hourly();" >/dev/null
|
||||
```
|
||||
|
||||
### Compatibility — existing `pg_cron` registrations
|
||||
Kubernetes (one-off `CronJob`):
|
||||
|
||||
If you previously registered the rollup as a `pg_cron` job (`SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', …)`), it is safe to leave it in place: advisory lock 4246 prevents double-writes, and the loser path no-ops cleanly. To drop the redundant entry once the in-process scheduler is up:
|
||||
|
||||
```sql
|
||||
SELECT cron.unschedule('rollup_task_usage_hourly')
|
||||
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: multica-usage-rollup
|
||||
spec:
|
||||
schedule: "*/5 * * * *"
|
||||
concurrencyPolicy: Forbid
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: psql
|
||||
image: postgres:17-alpine
|
||||
command:
|
||||
- psql
|
||||
- "$(DATABASE_URL)"
|
||||
- -c
|
||||
- "SELECT rollup_task_usage_hourly();"
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: multica-secrets
|
||||
key: DATABASE_URL
|
||||
```
|
||||
|
||||
External cron / systemd / Kubernetes `CronJob` setups that call `SELECT rollup_task_usage_hourly()` directly are also still valid — they were the only option before MUL-2957 and remain a supported compatibility path. They are no longer the recommended setup; new deployments should rely on the in-process scheduler.
|
||||
### Option B — Postgres with `pg_cron`
|
||||
|
||||
### Standalone backfill command
|
||||
If you'd rather have Postgres schedule itself, swap the bundled image for one that ships both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or a custom build of `pgvector/pgvector` with `pg_cron` added). `pg_cron` requires `shared_preload_libraries=pg_cron` in `postgresql.conf`, which only takes effect on Postgres restart — set it before bringing the container up.
|
||||
|
||||
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was claimed for the first time — most commonly when upgrading from `v0.3.4` to `v0.3.5+` on a database that already has months of usage — you can run `backfill_task_usage_hourly` to seed historical buckets:
|
||||
Then register the job once:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
SELECT cron.schedule(
|
||||
'rollup_task_usage_hourly',
|
||||
'*/5 * * * *',
|
||||
$$SELECT rollup_task_usage_hourly()$$
|
||||
);
|
||||
```
|
||||
|
||||
`pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, point `pg_cron` at it via that GUC or run `cron.schedule_in_database(...)` instead.
|
||||
|
||||
### Option C — Backfill historical data first
|
||||
|
||||
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was scheduled — most commonly when upgrading from `v0.3.4` to `v0.3.5+`, or on a fresh install that has been collecting usage for a while — run `backfill_task_usage_hourly` once to seed historical buckets, then set up Option A or Option B for ongoing rollups.
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
@@ -224,7 +260,7 @@ kubectl -n multica exec deploy/multica-backend -- \
|
||||
./backfill_task_usage_hourly --sleep-between-slices=2s
|
||||
```
|
||||
|
||||
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the in-process scheduler uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with the scheduler (advisory lock 4246 serialises them). Flags:
|
||||
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the cron path uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with an already-scheduled rollup. Flags:
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
@@ -236,9 +272,17 @@ After backfill completes, the rollup-state watermark is stamped to `now() - 5 mi
|
||||
|
||||
### `v0.3.4 → v0.3.5+` upgrade order
|
||||
|
||||
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. As of MUL-2957 the migrate command runs an idempotent monthly-slice backfill (under advisory lock 4246) **automatically** immediately before applying migration `103`, so v0.3.4 → v0.3.5+ upgrades complete in a single `migrate up` invocation — no operator step is required.
|
||||
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. If you run `migrate up` straight through on a database with existing `task_usage` rows, it aborts with:
|
||||
|
||||
If you are upgrading from a binary that pre-dates MUL-2957 (or the auto-hook fails for an environmental reason), recovery is the manual path: run `backfill_task_usage_hourly` against the database, then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and the in-process scheduler picks up new buckets from the first tick.
|
||||
```text
|
||||
ERROR: refusing to drop legacy daily rollups:
|
||||
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
|
||||
task_usage latest event (...) by more than 01:00:00 — backfill is
|
||||
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
|
||||
(and let pg_cron catch up) before re-running migrate
|
||||
```
|
||||
|
||||
Recovery is straightforward: run `backfill_task_usage_hourly` (Option C above), then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and migrations succeed, but the dashboard will still stay at zero until you set up Option A or Option B.
|
||||
|
||||
## Manual Setup (Without Docker Compose)
|
||||
|
||||
@@ -293,8 +337,6 @@ multica.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
> Even on a single domain, set `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS` to that public origin (e.g. `https://multica.example.com`) on the backend. The backend's default origin allowlist is `localhost` only, so without this it rejects the WebSocket upgrade from the public URL with `403` and real-time updates silently stop working. See [LAN / Non-localhost Access](#lan--non-localhost-access).
|
||||
|
||||
**Separate-domain layout** — frontend and backend on different hostnames:
|
||||
|
||||
```
|
||||
@@ -414,8 +456,6 @@ HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js
|
||||
|
||||
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
|
||||
|
||||
**Also required: allowlist the browser origin.** The two options above fix the WebSocket *upgrade proxying*, but a second, independent setting gates the connection: the backend validates the WebSocket `Origin` header against an allowlist that defaults to `localhost` only. When you open Multica from any other origin — a LAN IP **or a public domain behind a reverse proxy** — set `CORS_ALLOWED_ORIGINS` (or `FRONTEND_ORIGIN`) on the backend to that exact origin and restart, exactly as shown under [LAN / Non-localhost Access](#lan--non-localhost-access) above. Otherwise the upgrade is refused with `403`: the backend logs `websocket: request origin not allowed by Upgrader.CheckOrigin` and the browser console loops `disconnected, reconnecting in 3s`, while HTTP requests (and manual page refreshes) keep working because they are same-origin to the page. The single value covers both HTTP CORS and the WebSocket origin check.
|
||||
|
||||
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
|
||||
## Health Check
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
"electron-updater": "^6.8.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"lucide-react": "catalog:",
|
||||
"motion": "^12.38.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "^7.6.0",
|
||||
|
||||
@@ -98,29 +98,16 @@ export function stripLeadingSeparator(argv) {
|
||||
* - "v0.1.36" → "0.1.36"
|
||||
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
|
||||
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
|
||||
* - "f1415e96" (no tag) → "0.0.0-gf1415e96" (fallback)
|
||||
* - "2f24057b" (no tag, hash begins with a digit) → "0.0.0-g2f24057b"
|
||||
* - "0123456" (no tag, all-digit hash w/ leading zero) → "0.0.0-g0123456"
|
||||
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
|
||||
*
|
||||
* Leading `v` is stripped so the result is valid semver for package.json.
|
||||
* The fallback matters because a bare commit hash is never valid semver —
|
||||
* even one that happens to start with a digit (e.g. "2f24057b") — and
|
||||
* electron-updater throws on launch if package.json carries such a version.
|
||||
* The hash is prefixed with `g` so the pre-release identifier is always
|
||||
* alphanumeric; a bare all-digit hash with a leading zero (e.g. "0123456")
|
||||
* would otherwise form `0.0.0-0123456`, which is invalid semver.
|
||||
*/
|
||||
export function normalizeGitVersion(raw) {
|
||||
if (!raw) return null;
|
||||
const stripped = raw.replace(/^v/, "");
|
||||
// A real version begins with major.minor.patch. The bare commit hash
|
||||
// that `git describe --always` falls back to (no reachable tag) does not,
|
||||
// so coerce it to a 0.0.0 prerelease rather than passing it through.
|
||||
// Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
|
||||
// shorthand) so a hash like "0123456" yields "0.0.0-g0123456" — a single
|
||||
// alphanumeric identifier — instead of the invalid "0.0.0-0123456".
|
||||
if (!/^\d+\.\d+\.\d+/.test(stripped)) {
|
||||
return `0.0.0-g${stripped}`;
|
||||
if (!/^\d/.test(stripped)) {
|
||||
// No reachable tag — `git describe` fell back to just the commit hash.
|
||||
return `0.0.0-${stripped}`;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
@@ -38,27 +38,11 @@ describe("normalizeGitVersion", () => {
|
||||
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
|
||||
});
|
||||
|
||||
it("falls back to 0.0.0-g<hash> when no tags are reachable", () => {
|
||||
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
|
||||
// `git describe --tags --always` returns just the short commit hash
|
||||
// when there are no tags in the history at all. A hash that begins with
|
||||
// a digit (e.g. "2f24057b") is still not valid semver and must fall
|
||||
// through — otherwise electron-updater rejects it on launch. The `g`
|
||||
// prefix mirrors git describe's own `g<hash>` shorthand and keeps the
|
||||
// pre-release identifier a single alphanumeric token.
|
||||
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-gf1415e96");
|
||||
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-gabc1234");
|
||||
expect(normalizeGitVersion("2f24057b")).toBe("0.0.0-g2f24057b");
|
||||
});
|
||||
|
||||
it("prefixes an all-digit hash so the pre-release is valid semver", () => {
|
||||
// A short hash that is all decimal digits with a leading zero would
|
||||
// produce `0.0.0-0123456` — a numeric pre-release identifier must not
|
||||
// have a leading zero, so that value is invalid semver and
|
||||
// electron-updater would throw on the no-tag builds this fallback
|
||||
// exists to protect. The `g` prefix makes it a single alphanumeric
|
||||
// identifier, which is always valid.
|
||||
expect(normalizeGitVersion("0123456")).toBe("0.0.0-g0123456");
|
||||
expect(normalizeGitVersion("04567")).toBe("0.0.0-g04567");
|
||||
// when there are no tags in the history at all.
|
||||
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
|
||||
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// Capture every MenuItem the SUT constructs so each test can assert
|
||||
// on the menu that would appear at popup time without booting an
|
||||
// actual Electron window. State is created via `vi.hoisted` because
|
||||
// `vi.mock` factories are hoisted above all top-level statements; a
|
||||
// plain `const` would be a TDZ ReferenceError when the factory runs.
|
||||
type CapturedMenuItem = {
|
||||
label?: string;
|
||||
role?: string;
|
||||
type?: string;
|
||||
click?: () => void;
|
||||
};
|
||||
const ctx = vi.hoisted(() => ({
|
||||
capturedItems: [] as CapturedMenuItem[][],
|
||||
browserWindowFromWebContents: vi.fn(),
|
||||
popupSpy: vi.fn(),
|
||||
clipboardWriteText: vi.fn(),
|
||||
openExternalSpy: vi.fn().mockResolvedValue(undefined),
|
||||
preferredLanguagesRef: { current: ["en-US"] as string[] },
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => {
|
||||
class MockMenu {
|
||||
items: CapturedMenuItem[] = [];
|
||||
constructor() {
|
||||
ctx.capturedItems.push(this.items);
|
||||
}
|
||||
append(item: CapturedMenuItem) {
|
||||
this.items.push(item);
|
||||
}
|
||||
popup(opts: unknown) {
|
||||
ctx.popupSpy(opts);
|
||||
}
|
||||
}
|
||||
class MockMenuItem {
|
||||
label?: string;
|
||||
role?: string;
|
||||
type?: string;
|
||||
click?: () => void;
|
||||
constructor(opts: CapturedMenuItem) {
|
||||
Object.assign(this, opts);
|
||||
}
|
||||
}
|
||||
return {
|
||||
BrowserWindow: { fromWebContents: ctx.browserWindowFromWebContents },
|
||||
Menu: MockMenu,
|
||||
MenuItem: MockMenuItem,
|
||||
app: {
|
||||
getPreferredSystemLanguages: () => ctx.preferredLanguagesRef.current,
|
||||
},
|
||||
clipboard: { writeText: ctx.clipboardWriteText },
|
||||
shell: { openExternal: ctx.openExternalSpy },
|
||||
};
|
||||
});
|
||||
|
||||
import { installContextMenu } from "./context-menu";
|
||||
|
||||
type ContextMenuParams = {
|
||||
selectionText: string;
|
||||
isEditable: boolean;
|
||||
linkURL: string;
|
||||
editFlags: {
|
||||
canCut: boolean;
|
||||
canCopy: boolean;
|
||||
canPaste: boolean;
|
||||
canSelectAll: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type Listener = (event: unknown, params: ContextMenuParams) => void;
|
||||
|
||||
// Tiny WebContents stub — we only need the `.on("context-menu", ...)`
|
||||
// hook the SUT installs and a way to fire it back at our own listener
|
||||
// list. Everything else (clipboard, link opening, label resolution) is
|
||||
// mocked above.
|
||||
function makeWebContents() {
|
||||
const handlers: Listener[] = [];
|
||||
return {
|
||||
on(event: string, fn: Listener) {
|
||||
if (event === "context-menu") handlers.push(fn);
|
||||
},
|
||||
fire(params: ContextMenuParams) {
|
||||
for (const h of handlers) h({}, params);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseEditFlags = {
|
||||
canCut: false,
|
||||
canCopy: false,
|
||||
canPaste: false,
|
||||
canSelectAll: false,
|
||||
};
|
||||
|
||||
describe("installContextMenu — link items", () => {
|
||||
beforeEach(() => {
|
||||
ctx.capturedItems.length = 0;
|
||||
ctx.popupSpy.mockClear();
|
||||
ctx.clipboardWriteText.mockClear();
|
||||
ctx.openExternalSpy.mockClear();
|
||||
ctx.browserWindowFromWebContents.mockReset();
|
||||
ctx.preferredLanguagesRef.current = ["en-US"];
|
||||
});
|
||||
|
||||
it("adds 'Open Link in Browser' and 'Copy Link Address' when right-clicking an http(s) link", () => {
|
||||
// The link case is the one this test file is here to cover —
|
||||
// before MUL-3083 follow-up, right-clicking an <a> in the
|
||||
// renderer only surfaced 'copy' (when the user happened to have
|
||||
// text selected) and gave no way to open the URL externally.
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire({
|
||||
...baseSelection({ linkURL: "https://multica.ai/welcome" }),
|
||||
});
|
||||
|
||||
const labels = lastMenuLabels();
|
||||
expect(labels).toContain("Open Link in Browser");
|
||||
expect(labels).toContain("Copy Link Address");
|
||||
|
||||
// The two click handlers must route to the existing
|
||||
// openExternalSafely allowlist + clipboard.writeText.
|
||||
invokeByLabel("Open Link in Browser");
|
||||
expect(ctx.openExternalSpy).toHaveBeenCalledWith("https://multica.ai/welcome");
|
||||
|
||||
invokeByLabel("Copy Link Address");
|
||||
expect(ctx.clipboardWriteText).toHaveBeenCalledWith(
|
||||
"https://multica.ai/welcome",
|
||||
);
|
||||
expect(ctx.popupSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT add link items when the cursor is over a non-http(s) URL", () => {
|
||||
// Only http(s) links are surfaced — we don't promise anything for
|
||||
// mailto:, javascript:, custom app schemes, etc. Surfacing them
|
||||
// would shell out via openExternalSafely (which would block the
|
||||
// call anyway) or write a non-URL string to the clipboard, both
|
||||
// of which violate user expectations for a "link" item.
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire(baseSelection({ linkURL: "javascript:alert(1)" }));
|
||||
const labels = lastMenuLabelsOrEmpty();
|
||||
expect(labels).not.toContain("Open Link in Browser");
|
||||
expect(labels).not.toContain("Copy Link Address");
|
||||
});
|
||||
|
||||
it("does NOT add link items when there is no link under the cursor", () => {
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire({
|
||||
selectionText: "hello",
|
||||
isEditable: false,
|
||||
linkURL: "",
|
||||
editFlags: { ...baseEditFlags, canCopy: true },
|
||||
});
|
||||
const labels = lastMenuLabelsOrEmpty();
|
||||
expect(labels).not.toContain("Open Link in Browser");
|
||||
// Selection-only context still surfaces copy as before — guards
|
||||
// against a regression where adding the link branch broke the
|
||||
// base path.
|
||||
expect(menuItemRoles()).toContain("copy");
|
||||
});
|
||||
|
||||
it("uses zh-Hans labels when the OS preferred language is Chinese", () => {
|
||||
// Locale fallback is intentionally permissive: every zh-* variant
|
||||
// routes to zh-Hans so users on zh-CN / zh-TW / zh-HK still see
|
||||
// Chinese rather than dropping to English. The renderer ships only
|
||||
// zh-Hans translations, so this matches the rest of the app.
|
||||
ctx.preferredLanguagesRef.current = ["zh-CN"];
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
|
||||
expect(lastMenuLabels()).toContain("在浏览器中打开链接");
|
||||
expect(lastMenuLabels()).toContain("复制链接地址");
|
||||
});
|
||||
|
||||
it("falls back to English when the OS preferred language is something we don't ship", () => {
|
||||
ctx.preferredLanguagesRef.current = ["fr-FR"];
|
||||
const wc = makeWebContents();
|
||||
installContextMenu(wc as never);
|
||||
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
|
||||
expect(lastMenuLabels()).toContain("Open Link in Browser");
|
||||
});
|
||||
});
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
function baseSelection(over: Partial<ContextMenuParams>): ContextMenuParams {
|
||||
return {
|
||||
selectionText: "",
|
||||
isEditable: false,
|
||||
linkURL: "",
|
||||
editFlags: { ...baseEditFlags },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function lastMenu(): CapturedMenuItem[] {
|
||||
const last = ctx.capturedItems[ctx.capturedItems.length - 1];
|
||||
if (!last) throw new Error("no menu was constructed");
|
||||
return last;
|
||||
}
|
||||
|
||||
function lastMenuLabelsOrEmpty(): string[] {
|
||||
const last = ctx.capturedItems[ctx.capturedItems.length - 1] ?? [];
|
||||
return last.map((i) => i.label ?? "");
|
||||
}
|
||||
|
||||
function lastMenuLabels(): string[] {
|
||||
return lastMenu().map((i) => i.label ?? "");
|
||||
}
|
||||
|
||||
function menuItemRoles(): string[] {
|
||||
return lastMenu().map((i) => i.role ?? "");
|
||||
}
|
||||
|
||||
function invokeByLabel(label: string): void {
|
||||
const item = lastMenu().find((i) => i.label === label);
|
||||
if (!item) throw new Error(`menu item not found: ${label}`);
|
||||
item.click?.();
|
||||
}
|
||||
@@ -1,38 +1,12 @@
|
||||
import {
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
MenuItem,
|
||||
app,
|
||||
clipboard,
|
||||
type WebContents,
|
||||
} from "electron";
|
||||
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
|
||||
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
|
||||
|
||||
// Electron ships with no default right-click menu, so a user selecting text
|
||||
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
|
||||
// menu using `roles`, which keeps i18n + accelerator handling native.
|
||||
//
|
||||
// Custom (non-role) link items below are NOT auto-localized by Electron —
|
||||
// roles like "copy" pull labels from the OS, but a custom MenuItem only
|
||||
// shows the `label` you give it. We translate by OS-preferred language so
|
||||
// the link items at least track Chinese / Japanese / Korean speakers
|
||||
// alongside the English default; everything else falls through to English,
|
||||
// which matches Chrome's behavior on those locales without app-level
|
||||
// translation files.
|
||||
export function installContextMenu(webContents: WebContents): void {
|
||||
webContents.on("context-menu", (_event, params) => {
|
||||
const { editFlags, selectionText, isEditable, linkURL } = params;
|
||||
const { editFlags, selectionText, isEditable } = params;
|
||||
const hasSelection = selectionText.trim().length > 0;
|
||||
// params.linkURL is the resolved absolute URL of the anchor under the
|
||||
// cursor; Electron normalizes relative hrefs against the page URL for
|
||||
// us, so we only need to gate on the http(s) scheme allowlist
|
||||
// (mirrors openExternalSafely + the renderer's <a> usage). Empty for
|
||||
// non-link right-clicks; other schemes (mailto:, javascript:, custom
|
||||
// app schemes) are intentionally not surfaced — opening them via
|
||||
// shell.openExternal would route through the OS handler and is
|
||||
// outside what this menu promises.
|
||||
const linkIsHttpUrl = !!linkURL && isSafeExternalHttpUrl(linkURL);
|
||||
const labels = pickLabels();
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
@@ -52,87 +26,8 @@ export function installContextMenu(webContents: WebContents): void {
|
||||
menu.append(new MenuItem({ role: "selectAll" }));
|
||||
}
|
||||
|
||||
// Link items — only when the cursor is over an actual http(s) <a>.
|
||||
// Without these the renderer's <a target="_blank"> gives users no
|
||||
// standard right-click affordance ("Open in new window", "Copy link
|
||||
// address"); the default click handler does forward to
|
||||
// setWindowOpenHandler → openExternalSafely, but discoverability via
|
||||
// the keyboard / mouse context menu was missing.
|
||||
if (linkIsHttpUrl) {
|
||||
if (menu.items.length > 0) {
|
||||
menu.append(new MenuItem({ type: "separator" }));
|
||||
}
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: labels.openLink,
|
||||
click: () => {
|
||||
// openExternalSafely re-validates the scheme — defense in
|
||||
// depth in case Electron ever surfaces a non-http linkURL
|
||||
// we forgot to filter at this layer.
|
||||
void openExternalSafely(linkURL);
|
||||
},
|
||||
}),
|
||||
);
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: labels.copyLinkAddress,
|
||||
click: () => {
|
||||
clipboard.writeText(linkURL);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (menu.items.length === 0) return;
|
||||
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
|
||||
menu.popup({ window });
|
||||
});
|
||||
}
|
||||
|
||||
// Labels for the two link-related menu items in the user's OS-preferred
|
||||
// language, with English as the fallback. Kept inline because the main
|
||||
// process has no shared i18n loader (the renderer's i18next is per-window
|
||||
// and not reachable from here), and pulling one in for two strings would
|
||||
// be more rope than payload. Matches the four locales the renderer ships.
|
||||
type ContextMenuLabels = {
|
||||
openLink: string;
|
||||
copyLinkAddress: string;
|
||||
};
|
||||
|
||||
const labelsByLocale: Record<string, ContextMenuLabels> = {
|
||||
en: {
|
||||
openLink: "Open Link in Browser",
|
||||
copyLinkAddress: "Copy Link Address",
|
||||
},
|
||||
"zh-Hans": {
|
||||
openLink: "在浏览器中打开链接",
|
||||
copyLinkAddress: "复制链接地址",
|
||||
},
|
||||
ja: {
|
||||
openLink: "ブラウザでリンクを開く",
|
||||
copyLinkAddress: "リンクのアドレスをコピー",
|
||||
},
|
||||
ko: {
|
||||
openLink: "브라우저에서 링크 열기",
|
||||
copyLinkAddress: "링크 주소 복사",
|
||||
},
|
||||
};
|
||||
|
||||
// pickLabels resolves the OS-preferred language to one of the four
|
||||
// locales we ship copy for. We say "Open Link in Browser" rather than
|
||||
// "Open Link in New Window" because the link is opened via
|
||||
// shell.openExternal — it lands in the user's default browser, not in
|
||||
// another Multica window — so the wording matches what actually
|
||||
// happens.
|
||||
function pickLabels(): ContextMenuLabels {
|
||||
const preferred = app.getPreferredSystemLanguages()[0]?.toLowerCase() ?? "";
|
||||
if (preferred.startsWith("zh")) {
|
||||
// All Chinese variants get the Simplified copy — Multica only
|
||||
// ships zh-Hans, and zh-Hant users falling through to en would be
|
||||
// worse than reading Simplified Chinese.
|
||||
return labelsByLocale["zh-Hans"];
|
||||
}
|
||||
if (preferred.startsWith("ja")) return labelsByLocale.ja;
|
||||
if (preferred.startsWith("ko")) return labelsByLocale.ko;
|
||||
return labelsByLocale.en;
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { classifyAuthProbe, isAuthStatusError } from "./daemon-auth-probe";
|
||||
|
||||
describe("classifyAuthProbe", () => {
|
||||
it("treats a 401 as expired login", () => {
|
||||
expect(classifyAuthProbe({ status: 401 })).toBe("auth_expired");
|
||||
});
|
||||
|
||||
it("treats a missing token as expired login", () => {
|
||||
expect(classifyAuthProbe({ noToken: true })).toBe("auth_expired");
|
||||
});
|
||||
|
||||
it("treats a 2xx as a valid token (failure is non-auth)", () => {
|
||||
expect(classifyAuthProbe({ status: 200 })).toBe("ok");
|
||||
expect(classifyAuthProbe({ status: 204 })).toBe("ok");
|
||||
});
|
||||
|
||||
// The headline guard: a network failure must never be reported as an auth
|
||||
// problem — the daemon is just as unreachable for non-auth reasons.
|
||||
it("does NOT classify a network error as expired login", () => {
|
||||
expect(classifyAuthProbe({ networkError: true })).toBe("unknown");
|
||||
});
|
||||
|
||||
it("leaves 5xx and other statuses inconclusive", () => {
|
||||
expect(classifyAuthProbe({ status: 500 })).toBe("unknown");
|
||||
expect(classifyAuthProbe({ status: 503 })).toBe("unknown");
|
||||
expect(classifyAuthProbe({ status: 403 })).toBe("unknown");
|
||||
});
|
||||
|
||||
it("is inconclusive when nothing is known", () => {
|
||||
expect(classifyAuthProbe({})).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthStatusError", () => {
|
||||
it("is true only for a 401-tagged error (session token is dead)", () => {
|
||||
expect(isAuthStatusError(Object.assign(new Error("x"), { status: 401 }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The reviewer's must-fix: transient failures must NOT be treated as auth
|
||||
// failures (which would log the user out). A 5xx mint, a thrown fetch, a
|
||||
// file-write error — none carry status 401.
|
||||
it("is false for transient / non-401 failures", () => {
|
||||
expect(isAuthStatusError(Object.assign(new Error("x"), { status: 503 }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAuthStatusError(new Error("network down"))).toBe(false);
|
||||
expect(isAuthStatusError(new Error("EACCES: write failed"))).toBe(false);
|
||||
expect(isAuthStatusError(undefined)).toBe(false);
|
||||
expect(isAuthStatusError(null)).toBe(false);
|
||||
expect(isAuthStatusError("401")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Pure classification for the daemon auth probe. Kept free of Electron imports
|
||||
* so it can be unit-tested in jsdom.
|
||||
*
|
||||
* When the local daemon fails to reach "running" shortly after a start, the
|
||||
* main process probes the daemon's token against the backend (GET /api/me) to
|
||||
* tell "the daemon can't authenticate" apart from "the daemon is slow / the
|
||||
* network is down / it crashed for another reason". Misclassifying a network
|
||||
* blip as an auth failure would be worse than the original silent-Starting bug,
|
||||
* so the rules below are deliberately conservative: only an explicit 401 (or a
|
||||
* missing credential) is treated as auth-expired.
|
||||
*/
|
||||
|
||||
export interface AuthProbeOutcome {
|
||||
/** HTTP status code returned by the probe request, if one completed. */
|
||||
status?: number;
|
||||
/** The daemon profile has no token at all — there is nothing to validate. */
|
||||
noToken?: boolean;
|
||||
/** The probe request threw (timeout, connection refused, DNS, TLS). */
|
||||
networkError?: boolean;
|
||||
}
|
||||
|
||||
export type AuthProbeResult = "auth_expired" | "ok" | "unknown";
|
||||
|
||||
/**
|
||||
* Whether an error represents a genuine auth rejection (HTTP 401) as opposed to
|
||||
* a transient failure (5xx, network, local I/O). Used by the re-authenticate
|
||||
* flow so that only a real 401 — the session token itself is dead — forces a
|
||||
* full re-login; transient failures keep the user signed in to retry.
|
||||
*
|
||||
* `mintPat` attaches the response status to the error it throws, so a 401
|
||||
* surfaces here as `{ status: 401 }`. Everything else (no status, 5xx, a thrown
|
||||
* fetch, a file-write error) is treated as non-auth.
|
||||
*/
|
||||
export function isAuthStatusError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
(err as { status?: unknown }).status === 401
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyAuthProbe(outcome: AuthProbeOutcome): AuthProbeResult {
|
||||
// No credential to validate → the user must sign in.
|
||||
if (outcome.noToken) return "auth_expired";
|
||||
// Couldn't reach the server → this is a network problem, not an auth one.
|
||||
// Stay "unknown" so the caller keeps showing "starting"/"stopped" instead of
|
||||
// wrongly prompting for re-login.
|
||||
if (outcome.networkError) return "unknown";
|
||||
// The server explicitly rejected the token.
|
||||
if (outcome.status === 401) return "auth_expired";
|
||||
// The token is accepted — the daemon is failing for some other reason.
|
||||
if (outcome.status !== undefined && outcome.status >= 200 && outcome.status < 300) {
|
||||
return "ok";
|
||||
}
|
||||
// 5xx and everything else are inconclusive about the token's validity.
|
||||
return "unknown";
|
||||
}
|
||||
@@ -17,37 +17,14 @@ import {
|
||||
import { join } from "path";
|
||||
import { homedir, hostname } from "os";
|
||||
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
|
||||
import { daemonStatusAlive } from "../shared/daemon-types";
|
||||
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
|
||||
import { decideVersionAction } from "./version-decision";
|
||||
import {
|
||||
daemonLifecycleUnreachable,
|
||||
isDaemonExternallyManaged,
|
||||
normalizeHostOS,
|
||||
} from "./daemon-os";
|
||||
import {
|
||||
classifyAuthProbe,
|
||||
isAuthStatusError,
|
||||
type AuthProbeResult,
|
||||
} from "./daemon-auth-probe";
|
||||
|
||||
const DEFAULT_HEALTH_PORT = 19514;
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json");
|
||||
const LOG_TAIL_RETRY_MS = 2_000;
|
||||
const LOG_TAIL_MAX_RETRIES = 5;
|
||||
// How long a start may sit in "starting" (with no /health) before we probe the
|
||||
// token to find out whether login expired. The daemon's own startup can legitimately
|
||||
// take a while (it renews the PAT and lists workspaces before serving /health), so we
|
||||
// wait past the common case to avoid probing healthy-but-slow starts.
|
||||
const AUTH_PROBE_GRACE_MS = 10_000;
|
||||
// `multica daemon start` blocks until the daemon reports ready, polling /health
|
||||
// for up to its own startup timeout (45s in server/cmd/multica/cmd_daemon.go) to
|
||||
// cover cold-start agent-version detection. This execFile timeout MUST stay
|
||||
// above that — otherwise Electron kills the CLI supervisor mid-startup and a
|
||||
// healthy-but-slow start is misreported as a failure (the detached daemon child
|
||||
// keeps running, so the UI flashes "stopped" then "running").
|
||||
const DAEMON_START_EXEC_TIMEOUT_MS = 60_000;
|
||||
|
||||
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
|
||||
|
||||
@@ -71,15 +48,6 @@ let pendingVersionRestart = false;
|
||||
let targetApiBaseUrl: string | null = null;
|
||||
let activeProfile: ActiveProfile | null = null;
|
||||
|
||||
// Auth-probe state for the current start attempt. When a start fails to reach
|
||||
// "running", we probe the daemon's token once (after AUTH_PROBE_GRACE_MS) to
|
||||
// decide whether the cause is an expired/invalid login. `authExpired` is sticky
|
||||
// until the next start attempt or a successful /health, so the UI keeps showing
|
||||
// the re-login prompt instead of flapping back to "starting". See #3512.
|
||||
let startingSince: number | null = null;
|
||||
let authProbeDone = false;
|
||||
let authExpired = false;
|
||||
|
||||
// Serialize all writes to any profile config file. Multiple paths
|
||||
// (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers)
|
||||
// may try to write concurrently; chaining them avoids interleaved writes
|
||||
@@ -166,8 +134,6 @@ function sendStatus(status: DaemonStatus): void {
|
||||
interface HealthPayload {
|
||||
status?: string;
|
||||
pid?: number;
|
||||
/** Daemon's runtime.GOOS. Absent on daemons older than the #3916 fix. */
|
||||
os?: string;
|
||||
uptime?: string;
|
||||
daemon_id?: string;
|
||||
device_name?: string;
|
||||
@@ -195,36 +161,6 @@ async function fetchHealthAtPort(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the daemon profile's token against the backend to find out whether
|
||||
* a stuck start is an auth problem. Hits the same endpoint `multica auth status`
|
||||
* uses (GET /api/me) with the exact token the daemon loads from config.json, so
|
||||
* the verdict matches what the daemon itself would get from the server.
|
||||
*
|
||||
* Only the HTTP status is inspected (never the body) so a future change to the
|
||||
* /api/me response shape can't break this — a 401 means the token is rejected,
|
||||
* a 2xx means it's fine, and a thrown request means the network is the problem,
|
||||
* not auth. See classifyAuthProbe for the full rule set.
|
||||
*/
|
||||
async function probeTokenValidity(profile: string): Promise<AuthProbeResult> {
|
||||
if (!targetApiBaseUrl) return "unknown";
|
||||
const cfg = await readProfileConfig(profile);
|
||||
const token = typeof cfg.token === "string" ? cfg.token : "";
|
||||
if (!token) return classifyAuthProbe({ noToken: true });
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 4_000);
|
||||
const res = await fetch(`${targetApiBaseUrl.replace(/\/+$/, "")}/api/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
return classifyAuthProbe({ status: res.status });
|
||||
} catch {
|
||||
return classifyAuthProbe({ networkError: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop owns a dedicated CLI profile named after the target API host, so it
|
||||
// never reads or writes the user's hand-configured profiles. Profile dir:
|
||||
// ~/.multica/profiles/desktop-<host>/
|
||||
@@ -313,57 +249,12 @@ async function fetchHealth(): Promise<DaemonStatus> {
|
||||
const data = await fetchHealthAtPort(active.port);
|
||||
|
||||
if (!data || data.status !== "running") {
|
||||
// A start that never reaches "running" is the symptom; an expired/invalid
|
||||
// login is the most common cause and the one with no other signal (the
|
||||
// daemon exits before it can serve /health, so we can't read the reason
|
||||
// from it). Probe the token once per attempt, after a grace period, to
|
||||
// surface a re-login prompt instead of spinning on "starting" forever.
|
||||
if (
|
||||
currentState === "starting" &&
|
||||
!authExpired &&
|
||||
!authProbeDone &&
|
||||
startingSince !== null &&
|
||||
Date.now() - startingSince >= AUTH_PROBE_GRACE_MS
|
||||
) {
|
||||
authProbeDone = true;
|
||||
if ((await probeTokenValidity(active.name)) === "auth_expired") {
|
||||
authExpired = true;
|
||||
}
|
||||
}
|
||||
// Sticky: once login is known-expired, keep reporting it (even after
|
||||
// currentState flips away from "starting") until the next start attempt or
|
||||
// a successful /health clears the flag.
|
||||
if (authExpired) {
|
||||
return { state: "auth_expired", profile: active.name };
|
||||
}
|
||||
// The daemon binds /health before preflight finishes and self-reports
|
||||
// "starting" until it's ready. Trust that over our own currentState, so a
|
||||
// daemon booting on its own — or started via the CLI — surfaces as
|
||||
// "starting" instead of "stopped".
|
||||
if (data?.status === "starting") {
|
||||
return { state: "starting", profile: active.name };
|
||||
}
|
||||
return {
|
||||
state: currentState === "starting" ? "starting" : "stopped",
|
||||
profile: active.name,
|
||||
};
|
||||
}
|
||||
|
||||
// A live, authenticated daemon clears any prior auth-failure verdict so the
|
||||
// re-login prompt disappears once the user reconnects.
|
||||
authExpired = false;
|
||||
startingSince = null;
|
||||
|
||||
// A running daemon whose OS differs from this host's is one we can't drive
|
||||
// via the native lifecycle CLI (e.g. Linux-in-WSL2 behind a Windows desktop,
|
||||
// reachable only over localhost forwarding). Surface it so the UI disables
|
||||
// the auto-start/auto-stop toggles instead of letting them silently no-op,
|
||||
// and so before-quit skips a stop that would never land. See #3916.
|
||||
const externallyManaged = isDaemonExternallyManaged(
|
||||
data.os,
|
||||
normalizeHostOS(process.platform),
|
||||
);
|
||||
|
||||
// Safety: if we have a target URL and the daemon on our port reports a
|
||||
// different server_url, it's not "our" daemon — drop it and re-resolve.
|
||||
if (
|
||||
@@ -387,7 +278,6 @@ async function fetchHealth(): Promise<DaemonStatus> {
|
||||
: 0,
|
||||
profile: active.name,
|
||||
serverUrl: data.server_url,
|
||||
externallyManaged,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -574,15 +464,6 @@ async function ensureRunningDaemonVersionMatches(): Promise<
|
||||
> {
|
||||
const active = await ensureActiveProfile();
|
||||
const running = await fetchHealthAtPort(active.port);
|
||||
|
||||
// Don't try to version-match a daemon we can't restart (e.g. WSL2). Treat it
|
||||
// as up-to-date — restartDaemon would no-op anyway, and skipping here avoids
|
||||
// a misleading "restarting daemon" log on every auto-start. #3916.
|
||||
if (isDaemonExternallyManaged(running?.os, normalizeHostOS(process.platform))) {
|
||||
pendingVersionRestart = false;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
const bundled = await getCliBinaryVersion();
|
||||
const action = decideVersionAction(bundled, running);
|
||||
|
||||
@@ -634,13 +515,7 @@ async function mintPat(jwt: string): Promise<string> {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
// Attach the status so callers can tell a genuine auth rejection (401 — the
|
||||
// session token is dead) apart from a transient failure (5xx, etc.) without
|
||||
// string-matching the message.
|
||||
throw Object.assign(
|
||||
new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`),
|
||||
{ status: res.status },
|
||||
);
|
||||
throw new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { token?: unknown };
|
||||
if (typeof data.token !== "string" || !data.token.startsWith("mul_")) {
|
||||
@@ -705,10 +580,7 @@ async function syncToken(
|
||||
if (userChanged) {
|
||||
try {
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (daemonStatusAlive(existing?.status)) {
|
||||
// Restart whether it's "running" or still "starting" — a booting daemon
|
||||
// already loaded the old token at startup, so it must be restarted to
|
||||
// pick up the rotated credentials.
|
||||
if (existing?.status === "running") {
|
||||
console.log(
|
||||
"[daemon] user switched — restarting daemon with new credentials",
|
||||
);
|
||||
@@ -748,52 +620,6 @@ async function clearToken(): Promise<void> {
|
||||
await removeProfileUserId(active.name);
|
||||
}
|
||||
|
||||
// Result of a user-initiated daemon re-authentication. The distinction matters:
|
||||
// only `session_invalid` justifies signing the user out of the whole app; a
|
||||
// `transient` failure must keep them logged in so they can retry.
|
||||
export type ReauthResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: "session_invalid" }
|
||||
| { ok: false; reason: "transient"; message: string };
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the local daemon from the "auth_expired" state. Drops the stale
|
||||
* cached PAT, mints a fresh one from the current session token, and restarts
|
||||
* the daemon so it loads the new credential.
|
||||
*
|
||||
* Failures are classified rather than collapsed: a 401 from the mint means the
|
||||
* session token itself is dead (`session_invalid` → the renderer drives a full
|
||||
* re-login); anything else — mint 5xx, a network blip, a config write error, a
|
||||
* restart hiccup — is `transient`, leaving the user signed in so they can retry.
|
||||
* This mirrors the conservative classification the startup probe already uses.
|
||||
*/
|
||||
async function reauthenticate(
|
||||
token: string,
|
||||
userId: string,
|
||||
): Promise<ReauthResult> {
|
||||
try {
|
||||
await clearToken();
|
||||
// syncToken mints a fresh PAT because clearToken just removed any cache.
|
||||
await syncToken(token, userId);
|
||||
} catch (err) {
|
||||
if (isAuthStatusError(err)) return { ok: false, reason: "session_invalid" };
|
||||
return { ok: false, reason: "transient", message: errorMessage(err) };
|
||||
}
|
||||
const restart = await restartDaemon();
|
||||
if (!restart.success) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "transient",
|
||||
message: restart.error ?? "failed to restart daemon",
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function withGuard<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
|
||||
if (operationInProgress) {
|
||||
return { success: false, error: "Another daemon operation is in progress" };
|
||||
@@ -825,19 +651,12 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (daemonStatusAlive(existing?.status)) {
|
||||
// A daemon is already up ("running") or booting ("starting") on this port —
|
||||
// don't spawn a second one (the CLI rejects that as "already running").
|
||||
// Let polling track it through to "running".
|
||||
if (existing?.status === "running") {
|
||||
pollOnce();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
currentState = "starting";
|
||||
// Begin a fresh auth-probe window for this attempt.
|
||||
startingSince = Date.now();
|
||||
authProbeDone = false;
|
||||
authExpired = false;
|
||||
sendStatus({ state: "starting" });
|
||||
|
||||
const args = ["daemon", "start", ...profileArgs(active)];
|
||||
@@ -846,7 +665,7 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
execFile(
|
||||
bin,
|
||||
args,
|
||||
{ timeout: DAEMON_START_EXEC_TIMEOUT_MS, env: desktopSpawnEnv() },
|
||||
{ timeout: 20_000, env: desktopSpawnEnv() },
|
||||
(err) => {
|
||||
if (err) {
|
||||
currentState = "stopped";
|
||||
@@ -864,40 +683,12 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh boundary preflight for stop/restart: read the active profile's CURRENT
|
||||
* /health and decide whether the daemon runs somewhere the app can't drive
|
||||
* (WSL2 etc.). Done per call rather than off the poll cache, so a lifecycle op
|
||||
* never shells out to a CLI that can't reach the daemon's process — even on
|
||||
* paths that didn't just poll (e.g. restart-on-user-switch in syncToken, which
|
||||
* calls restartDaemon directly). See #3916.
|
||||
*/
|
||||
async function lifecycleBlockedByForeignDaemon(): Promise<boolean> {
|
||||
const active = await ensureActiveProfile();
|
||||
return daemonLifecycleUnreachable(
|
||||
async () => (await fetchHealthAtPort(active.port))?.os,
|
||||
normalizeHostOS(process.platform),
|
||||
);
|
||||
}
|
||||
|
||||
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
// Central lifecycle guard: a daemon running in an environment we can't drive
|
||||
// (e.g. Linux in WSL2 behind a Windows desktop) can't be stopped by the
|
||||
// native CLI — it would act on the host process namespace and no-op, while
|
||||
// still flipping our state to "stopped". Bail as a successful no-op so every
|
||||
// caller (logout, quit, restart, the Runtime card) is covered in one place
|
||||
// rather than each remembering to check. Preflighted against live /health so
|
||||
// it holds even when no poll ran first. #3916.
|
||||
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
|
||||
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return { success: false, error: "multica CLI is not installed" };
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
currentState = "stopping";
|
||||
// An explicit stop is a clean reset — drop any pending auth-failure verdict.
|
||||
authExpired = false;
|
||||
startingSince = null;
|
||||
sendStatus({ state: "stopping" });
|
||||
|
||||
const args = ["daemon", "stop", ...profileArgs(active)];
|
||||
@@ -916,11 +707,6 @@ async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
// Same central, live-preflighted guard as stopDaemon: we can neither stop nor
|
||||
// start a daemon we don't manage, so don't try (user-switch, reauth,
|
||||
// first-workspace, and any future restart caller all route through here).
|
||||
// #3916.
|
||||
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
|
||||
const stopResult = await stopDaemon();
|
||||
if (!stopResult.success) return stopResult;
|
||||
return startDaemon();
|
||||
@@ -1088,10 +874,6 @@ export function setupDaemonManager(
|
||||
(_event, token: string, userId: string) => syncToken(token, userId),
|
||||
);
|
||||
ipcMain.handle("daemon:clear-token", () => clearToken());
|
||||
ipcMain.handle(
|
||||
"daemon:reauthenticate",
|
||||
(_event, token: string, userId: string) => reauthenticate(token, userId),
|
||||
);
|
||||
ipcMain.handle("daemon:is-cli-installed", async () => {
|
||||
const bin = await resolveCliBinary();
|
||||
return bin !== null;
|
||||
@@ -1168,8 +950,6 @@ export function setupDaemonManager(
|
||||
isQuitting = true;
|
||||
event.preventDefault();
|
||||
try {
|
||||
// stopDaemon no-ops for an externally-managed daemon (WSL2 etc.), so
|
||||
// this is safe and instant in that case — the guard lives there. #3916
|
||||
await stopDaemon();
|
||||
} catch {
|
||||
// Best-effort stop on quit
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
daemonLifecycleUnreachable,
|
||||
isDaemonExternallyManaged,
|
||||
normalizeHostOS,
|
||||
} from "./daemon-os";
|
||||
|
||||
describe("normalizeHostOS", () => {
|
||||
it("maps win32 to the GOOS spelling 'windows'", () => {
|
||||
expect(normalizeHostOS("win32")).toBe("windows");
|
||||
});
|
||||
|
||||
it("passes darwin and linux through unchanged (already GOOS spellings)", () => {
|
||||
expect(normalizeHostOS("darwin")).toBe("darwin");
|
||||
expect(normalizeHostOS("linux")).toBe("linux");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDaemonExternallyManaged", () => {
|
||||
it("flags a Linux (WSL2) daemon behind a Windows desktop — the #3916 case", () => {
|
||||
expect(isDaemonExternallyManaged("linux", normalizeHostOS("win32"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// These three are the "不误伤" guarantees: a native daemon on each platform
|
||||
// must keep its auto-start/auto-stop toggles.
|
||||
it("does NOT flag a native Windows daemon under a Windows desktop", () => {
|
||||
expect(isDaemonExternallyManaged("windows", normalizeHostOS("win32"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT flag a native macOS daemon under a macOS desktop", () => {
|
||||
expect(isDaemonExternallyManaged("darwin", normalizeHostOS("darwin"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT flag a native Linux daemon under a Linux desktop", () => {
|
||||
expect(isDaemonExternallyManaged("linux", normalizeHostOS("linux"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Fail safe: an older daemon that predates the `os` field reports nothing.
|
||||
// Hiding a toggle on a guess would 误伤, so unknown OS = treat as manageable.
|
||||
it("fails safe to false when the daemon reports no OS", () => {
|
||||
expect(isDaemonExternallyManaged(undefined, "windows")).toBe(false);
|
||||
expect(isDaemonExternallyManaged("", "windows")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The stop/restart lifecycle boundary funnels through this. It must read the
|
||||
// daemon's LIVE OS (not a cached poll value), so a restart on a path that
|
||||
// didn't just poll — e.g. user-switch — still can't shell out at a WSL2 daemon.
|
||||
describe("daemonLifecycleUnreachable", () => {
|
||||
it("consults the live OS reader and blocks a foreign-OS (WSL2) daemon", async () => {
|
||||
const readDaemonOS = vi.fn().mockResolvedValue("linux");
|
||||
expect(await daemonLifecycleUnreachable(readDaemonOS, "windows")).toBe(true);
|
||||
// Proves the decision came from a fresh read, not a stale cache.
|
||||
expect(readDaemonOS).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows a native daemon whose live OS matches the host", async () => {
|
||||
expect(
|
||||
await daemonLifecycleUnreachable(async () => "windows", "windows"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await daemonLifecycleUnreachable(async () => "darwin", "darwin"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails safe to false when the live OS is unknown (older daemon / none running)", async () => {
|
||||
expect(
|
||||
await daemonLifecycleUnreachable(async () => undefined, "windows"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Detecting a daemon the desktop app can't manage.
|
||||
*
|
||||
* The app reads daemon liveness over HTTP at 127.0.0.1:{port}/health, but it
|
||||
* starts/stops the daemon by shelling out to the bundled native CLI, which acts
|
||||
* on the *host* OS process namespace. On Windows with the daemon running inside
|
||||
* WSL2, /health is reachable via localhost forwarding (so status looks fine) but
|
||||
* the daemon's process lives in a separate Linux namespace the Windows CLI can't
|
||||
* touch — so auto-start / auto-stop silently do nothing (#3916).
|
||||
*
|
||||
* The reliable, low-false-positive signal is the daemon's own OS (reported as
|
||||
* `os` on /health, = runtime.GOOS) vs the desktop host OS. They only disagree
|
||||
* when the daemon runs in a foreign environment we can't drive. This module is
|
||||
* the single source of truth for that comparison so it stays unit-tested — the
|
||||
* cost of a false positive is hiding a working toggle from a native user, so the
|
||||
* logic must fail safe (treat unknown / matching as manageable).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a Node `process.platform` value to the daemon's `runtime.GOOS`
|
||||
* vocabulary so the two are directly comparable. Only `win32` -> `windows`
|
||||
* actually differs across the platforms we ship (darwin/linux already match);
|
||||
* any other value passes through unchanged.
|
||||
*/
|
||||
export function normalizeHostOS(platform: NodeJS.Platform): string {
|
||||
return platform === "win32" ? "windows" : platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a running daemon is in an environment the desktop app can't control.
|
||||
*
|
||||
* Returns true ONLY when the daemon reports a concrete OS that differs from the
|
||||
* host's. Fails safe to false when:
|
||||
* - `daemonOS` is missing/empty (older daemon that predates the `os` field, or
|
||||
* a malformed response) — we can't prove it's foreign, so keep toggles live.
|
||||
* - the OSes match — a normally-managed native daemon.
|
||||
*
|
||||
* Callers must only invoke this for a daemon that is actually running; a stopped
|
||||
* daemon has no OS to compare and its toggles must stay enabled.
|
||||
*/
|
||||
export function isDaemonExternallyManaged(
|
||||
daemonOS: string | undefined,
|
||||
hostOS: string,
|
||||
): boolean {
|
||||
if (typeof daemonOS !== "string" || daemonOS.length === 0) return false;
|
||||
return daemonOS !== hostOS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary preflight for daemon lifecycle ops (stop / restart): resolve the
|
||||
* daemon's CURRENT OS via `readDaemonOS` and return true when it's running
|
||||
* somewhere the app can't drive.
|
||||
*
|
||||
* `readDaemonOS` is a live `/health` read performed at the call site — never a
|
||||
* cached poll value. That is the whole point: a stale "manageable" cache would
|
||||
* let a lifecycle op shell out to a native CLI that can't reach a WSL2 daemon
|
||||
* (the PID lives in another namespace), which is exactly the bug. Taking the
|
||||
* reader as a parameter keeps this unit-testable without the electron-coupled
|
||||
* daemon-manager module, and lets the test prove the live value — not a cache —
|
||||
* drives the decision. See #3916.
|
||||
*/
|
||||
export async function daemonLifecycleUnreachable(
|
||||
readDaemonOS: () => Promise<string | undefined>,
|
||||
hostOS: string,
|
||||
): Promise<boolean> {
|
||||
return isDaemonExternallyManaged(await readDaemonOS(), hostOS);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
type FreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Each test gets its own temp dir so the on-disk breadcrumb is isolated.
|
||||
const dirs: string[] = [];
|
||||
function tempFile(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "freeze-breadcrumb-"));
|
||||
dirs.push(dir);
|
||||
return join(dir, "last-client-failure.json");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const sample: FreezeBreadcrumb = {
|
||||
kind: "unresponsive",
|
||||
context: { desktopRoute: { path: "/acme/issues" } },
|
||||
ts: 1_700_000_000_000,
|
||||
version: "0.3.1",
|
||||
};
|
||||
|
||||
describe("freeze breadcrumb round-trip", () => {
|
||||
it("writes then reads back the breadcrumb", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
});
|
||||
|
||||
it("read clears the file so a failure reports exactly once", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearFreezeBreadcrumb removes a pending breadcrumb (hang recovered)", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
clearFreezeBreadcrumb(file);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The breadcrumb crosses a process boundary (main writes, renderer flushes via
|
||||
// IPC) and lives across app versions — a future write shape or a corrupt file
|
||||
// must never throw into boot. CLAUDE.md "API Response Compatibility".
|
||||
describe("freeze breadcrumb defends against malformed input", () => {
|
||||
it("returns null when no file exists", () => {
|
||||
expect(readAndClearFreezeBreadcrumb(tempFile())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on corrupt JSON", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "{ not valid json", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is missing", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ ts: 1, version: "x" }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is the wrong type", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ kind: 42, context: {} }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on a JSON null payload", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "null", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing a non-existent file is a no-op, never throws", () => {
|
||||
expect(() => clearFreezeBreadcrumb(tempFile())).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
// When the renderer truly hangs or its process dies, it can't send telemetry
|
||||
// itself — the thread is blocked or gone. The main process (always alive) is
|
||||
// the only watcher that can react, but during the hang it can't reach the
|
||||
// renderer's posthog-js either. So it writes a breadcrumb to disk; the next
|
||||
// time a renderer boots, it reads + clears the file and reports the event.
|
||||
// This survives even a force-quit, which is the whole point.
|
||||
|
||||
export type { FreezeBreadcrumb };
|
||||
|
||||
/**
|
||||
* Best-effort write. A breadcrumb we can't persist is lost, never fatal.
|
||||
*
|
||||
* Known limitation: this is a single slot — last write wins. Multiple failures
|
||||
* within one session collapse to the last one, so per-session failure counts
|
||||
* are undercounted. Acceptable for now: telemetry aggregates presence and
|
||||
* frequency across users, not exhaustive per-session sequences. Upgrade to an
|
||||
* append/ring buffer if per-session failure chains become a question.
|
||||
*/
|
||||
export function writeFreezeBreadcrumb(filePath: string, breadcrumb: FreezeBreadcrumb): void {
|
||||
try {
|
||||
writeFileSync(filePath, JSON.stringify(breadcrumb), "utf8");
|
||||
} catch {
|
||||
// Disk full / permissions — drop silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a persisted breadcrumb. Called when the renderer recovers from a hang
|
||||
* (a `responsive` event after `unresponsive`): the breadcrumb was written
|
||||
* pre-emptively while the thread was stuck, but since it came back, the
|
||||
* in-thread long-task watchdog already reports it — keeping the breadcrumb
|
||||
* would double-count it AND mislabel a recovered window as `recovered: false`.
|
||||
* Best-effort; a stale breadcrumb only costs one duplicate report.
|
||||
*/
|
||||
export function clearFreezeBreadcrumb(filePath: string): void {
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// Nothing to clear / permissions — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the breadcrumb and delete it in the same call, so a failure is reported
|
||||
* exactly once. Returns null when there's no breadcrumb (the normal case) or
|
||||
* when the file is unreadable / corrupt.
|
||||
*/
|
||||
export function readAndClearFreezeBreadcrumb(filePath: string): FreezeBreadcrumb | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// If we can't delete it we'd re-report next launch; acceptable over throwing.
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
typeof (parsed as FreezeBreadcrumb).kind === "string"
|
||||
) {
|
||||
return parsed as FreezeBreadcrumb;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt JSON — drop.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeImage, Notification } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
@@ -13,21 +13,6 @@ import { installNavigationGestures } from "./navigation-gestures";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
sanitizeRendererRouteContext,
|
||||
type RendererRouteContext,
|
||||
} from "../shared/renderer-route-context";
|
||||
import {
|
||||
createElectronReloadPrompt,
|
||||
installRendererRecoveryHandlers,
|
||||
type RendererRecoveryWindow,
|
||||
} from "./renderer-recovery";
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
@@ -71,15 +56,7 @@ if (process.platform !== "win32") {
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
// Where the main process parks a freeze/crash breadcrumb until the next
|
||||
// renderer boot flushes it to telemetry. Lives in userData so it survives a
|
||||
// force-quit. Resolved lazily — app.getPath is only valid after `ready`.
|
||||
function freezeBreadcrumbPath(): string {
|
||||
return join(app.getPath("userData"), "last-client-failure.json");
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let latestRendererRouteContext: RendererRouteContext | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
@@ -144,7 +121,7 @@ function createWindow(): void {
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 16, y: 17 },
|
||||
trafficLightPosition: { x: 16, y: 13 },
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// Windows/Linux pick up the window/taskbar icon from this option.
|
||||
@@ -183,19 +160,10 @@ function createWindow(): void {
|
||||
additionalArguments: [`--multica-locale=${systemLocale}`],
|
||||
},
|
||||
});
|
||||
const window = mainWindow;
|
||||
latestRendererRouteContext = null;
|
||||
|
||||
window.on("closed", () => {
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
latestRendererRouteContext = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Strip Origin header from WebSocket upgrade requests so the server's
|
||||
// origin whitelist doesn't reject connections from localhost dev origins.
|
||||
window.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ["wss://*/*", "ws://*/*"] },
|
||||
(details, callback) => {
|
||||
delete details.requestHeaders["Origin"];
|
||||
@@ -203,8 +171,8 @@ function createWindow(): void {
|
||||
},
|
||||
);
|
||||
|
||||
window.on("ready-to-show", () => {
|
||||
window.show();
|
||||
mainWindow.on("ready-to-show", () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Detect OS language changes while the app is running. Electron has no
|
||||
@@ -212,28 +180,24 @@ function createWindow(): void {
|
||||
// catches the common case where users switch System Settings → Language
|
||||
// and bring the app back. The renderer decides whether to act (it ignores
|
||||
// the signal when the user has an explicit Settings choice).
|
||||
window.on("focus", () => {
|
||||
mainWindow.on("focus", () => {
|
||||
const current = getSystemLocale();
|
||||
if (current === lastKnownSystemLocale) return;
|
||||
lastKnownSystemLocale = current;
|
||||
window.webContents.send("locale:system-changed", current);
|
||||
mainWindow?.webContents.send("locale:system-changed", current);
|
||||
});
|
||||
|
||||
window.webContents.setWindowOpenHandler((details) => {
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
openExternalSafely(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Window-level keyboard shortcuts. Calling preventDefault here prevents
|
||||
// both the renderer keydown AND the application menu accelerator, so
|
||||
// anything we own here (reload-block, zoom, tab-close) is the sole handler
|
||||
// for that combination — no double-fire with the macOS default View menu.
|
||||
window.webContents.on("before-input-event", (event, input) => {
|
||||
const result = handleAppShortcut(input, window.webContents);
|
||||
if (result === "close-tab") {
|
||||
event.preventDefault();
|
||||
window.webContents.send("tab:close-active");
|
||||
} else if (result) {
|
||||
// anything we own here (reload-block, zoom) is the sole handler for
|
||||
// that combination — no double-fire with the macOS default View menu.
|
||||
mainWindow.webContents.on("before-input-event", (event, input) => {
|
||||
if (handleAppShortcut(input, mainWindow!.webContents)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
@@ -255,15 +219,22 @@ function createWindow(): void {
|
||||
// Forward every renderer-side console.* call. The detail object also
|
||||
// carries source URL + line — included so a thrown stack trace from
|
||||
// window.onerror is traceable back to a file.
|
||||
window.webContents.on("console-message", (details) => {
|
||||
mainWindow.webContents.on("console-message", (details) => {
|
||||
const { level, message, sourceId, lineNumber } = details;
|
||||
log(level, `${message} (${sourceId}:${lineNumber})`);
|
||||
});
|
||||
|
||||
// Fires when the renderer process dies for any reason (OOM, crash,
|
||||
// killed). `details.reason` is the discriminator: "crashed", "oom",
|
||||
// "killed", "abnormal-exit", "launch-failed", etc.
|
||||
mainWindow.webContents.on("render-process-gone", (_event, details) => {
|
||||
log("process-gone", JSON.stringify(details));
|
||||
});
|
||||
|
||||
// Fires when loadURL / loadFile can't reach its target (dev server
|
||||
// not up yet, network blip, file missing). errorCode is a Chromium
|
||||
// net error number; -3 = ABORTED is normal during HMR and skipped.
|
||||
window.webContents.on(
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (errorCode === -3) return;
|
||||
@@ -274,43 +245,21 @@ function createWindow(): void {
|
||||
},
|
||||
);
|
||||
|
||||
// Fires when the preload script throws before the renderer can boot.
|
||||
// This is the one error class that NEVER reaches DevTools (preload
|
||||
// runs before any window) — without this listener it's invisible.
|
||||
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
log("preload-error", `path=${preloadPath} err=${error?.stack ?? error}`);
|
||||
});
|
||||
}
|
||||
|
||||
installRendererRecoveryHandlers(window as unknown as RendererRecoveryWindow, {
|
||||
isDev: is.dev,
|
||||
showReloadPrompt: createElectronReloadPrompt((options) =>
|
||||
dialog.showMessageBox(window, options),
|
||||
),
|
||||
getDiagnosticContext: () => ({
|
||||
windowUrl: window.webContents.getURL(),
|
||||
...(latestRendererRouteContext
|
||||
? { desktopRoute: latestRendererRouteContext }
|
||||
: {}),
|
||||
}),
|
||||
// Only persist in production: a true hang/crash can't report itself, so we
|
||||
// write a breadcrumb and the next renderer boot flushes it to PostHog. Dev
|
||||
// is excluded to keep field telemetry clean.
|
||||
persistBreadcrumb: is.dev
|
||||
? undefined
|
||||
: (payload) =>
|
||||
writeFreezeBreadcrumb(freezeBreadcrumbPath(), {
|
||||
kind: payload.kind,
|
||||
context: payload.context,
|
||||
ts: Date.now(),
|
||||
version: getAppVersion(),
|
||||
}),
|
||||
clearBreadcrumb: is.dev
|
||||
? undefined
|
||||
: () => clearFreezeBreadcrumb(freezeBreadcrumbPath()),
|
||||
});
|
||||
|
||||
installContextMenu(window.webContents);
|
||||
installNavigationGestures(window);
|
||||
installContextMenu(mainWindow.webContents);
|
||||
installNavigationGestures(mainWindow);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
window.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
} else {
|
||||
window.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,11 +366,6 @@ if (!gotTheLock) {
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
// Renderer requests window close (e.g. Cmd+W on last tab).
|
||||
ipcMain.on("window:close", () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle("file:download-url", (_event, url: string) => {
|
||||
if (!mainWindow) {
|
||||
console.warn("[download] ignored file:download-url — mainWindow torn down");
|
||||
@@ -440,14 +384,6 @@ if (!gotTheLock) {
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: read + clear any freeze/crash breadcrumb left by a previous
|
||||
// session. The renderer flushes it to telemetry on boot (it couldn't be
|
||||
// reported when it happened — the renderer was hung or gone). Read-and-
|
||||
// clear so a failure reports exactly once.
|
||||
ipcMain.on("freeze:get-last", (event) => {
|
||||
event.returnValue = readAndClearFreezeBreadcrumb(freezeBreadcrumbPath());
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
@@ -455,13 +391,6 @@ if (!gotTheLock) {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
});
|
||||
|
||||
ipcMain.on(RENDERER_ROUTE_CONTEXT_CHANNEL, (event, context: unknown) => {
|
||||
if (!mainWindow || event.sender !== mainWindow.webContents) return;
|
||||
const sanitized = sanitizeRendererRouteContext(context);
|
||||
if (!sanitized) return;
|
||||
latestRendererRouteContext = sanitized;
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
@@ -14,14 +14,13 @@ function makeWc(initialLevel = 0) {
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta" | "shift">> = {},
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
|
||||
): ShortcutInput {
|
||||
return {
|
||||
type: "keyDown",
|
||||
key: k,
|
||||
control: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
...mods,
|
||||
};
|
||||
}
|
||||
@@ -151,36 +150,3 @@ describe("handleAppShortcut — unrelated keys pass through", () => {
|
||||
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — close tab (Cmd/Ctrl+W)", () => {
|
||||
it('returns "close-tab" on Cmd+W (macOS)', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w", { meta: true }), wc, "darwin")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it('returns "close-tab" on Cmd+W uppercase', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { meta: true }), wc, "darwin")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it('returns "close-tab" on Ctrl+W (Linux/Windows)', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w", { control: true }), wc, "linux")).toBe("close-tab");
|
||||
expect(handleAppShortcut(key("w", { control: true }), wc, "win32")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it("does not trigger without Cmd/Ctrl modifier", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w"), wc, "darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trigger on Cmd+Shift+W (reserved for close-window)", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { meta: true, shift: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trigger on Ctrl+Shift+W (reserved for close-window)", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { control: true, shift: true }), wc, "linux")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ export type ShortcutInput = {
|
||||
key: string;
|
||||
control: boolean;
|
||||
meta: boolean;
|
||||
shift: boolean;
|
||||
};
|
||||
|
||||
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
|
||||
@@ -35,19 +34,11 @@ const ZOOM_MAX = 4.5;
|
||||
* Handling the shortcuts here gives identical behavior on every platform
|
||||
* and every layout.
|
||||
*/
|
||||
/**
|
||||
* Result of handleAppShortcut:
|
||||
* - `false`: not handled, let Electron continue
|
||||
* - `true`: handled (preventDefault), no further action
|
||||
* - `"close-tab"`: Cmd/Ctrl+W intercepted — caller should send IPC to renderer
|
||||
*/
|
||||
export type ShortcutResult = boolean | "close-tab";
|
||||
|
||||
export function handleAppShortcut(
|
||||
input: ShortcutInput,
|
||||
webContents: ZoomTarget,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): ShortcutResult {
|
||||
): boolean {
|
||||
if (input.type !== "keyDown") return false;
|
||||
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
|
||||
|
||||
@@ -79,12 +70,5 @@ export function handleAppShortcut(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + W → close active tab (or window if last tab).
|
||||
// Cmd/Ctrl + Shift + W is reserved for "close window" — do not intercept.
|
||||
// Return a signal so the caller can send IPC to the renderer.
|
||||
if (input.key.toLowerCase() === "w" && !input.shift) {
|
||||
return "close-tab";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createElectronReloadPrompt, installRendererRecoveryHandlers } from "./renderer-recovery";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
function makeWindow() {
|
||||
const windowHandlers = new Map<string, Handler>();
|
||||
const webContentsHandlers = new Map<string, Handler>();
|
||||
const reload = vi.fn();
|
||||
return {
|
||||
window: {
|
||||
on: vi.fn((event: string, handler: Handler) => windowHandlers.set(event, handler)),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
webContents: {
|
||||
on: vi.fn((event: string, handler: Handler) => webContentsHandlers.set(event, handler)),
|
||||
reload,
|
||||
},
|
||||
},
|
||||
windowHandlers,
|
||||
webContentsHandlers,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("installRendererRecoveryHandlers", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("registers production reload prompts for renderer death and preload failure without auto reloading", async () => {
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "reload" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt });
|
||||
|
||||
expect(fixture.webContentsHandlers.has("render-process-gone")).toBe(true);
|
||||
expect(fixture.webContentsHandlers.has("preload-error")).toBe(true);
|
||||
expect(fixture.windowHandlers.has("unresponsive")).toBe(true);
|
||||
expect(fixture.windowHandlers.has("responsive")).toBe(true);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
|
||||
fixture.webContentsHandlers.get("preload-error")?.({}, "/preload.js", new Error("boom"));
|
||||
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not prompt when the renderer exits cleanly", async () => {
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "reload" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt });
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showReloadPrompt).not.toHaveBeenCalled();
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels an unresponsive prompt when the window becomes responsive again", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "reload" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).not.toHaveBeenCalled();
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts for sustained unresponsive windows and only reloads after user confirmation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
|
||||
const desktopRoute = {
|
||||
surface: "tab",
|
||||
path: "/acme/issues/MUL-3239",
|
||||
workspaceSlug: "acme",
|
||||
tabId: "tab-1",
|
||||
reportedAt: "2026-06-15T00:00:00.000Z",
|
||||
};
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext: () => ({
|
||||
windowUrl:
|
||||
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
|
||||
desktopRoute,
|
||||
}),
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledWith({
|
||||
kind: "unresponsive",
|
||||
context: {
|
||||
windowUrl:
|
||||
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
|
||||
desktopRoute,
|
||||
},
|
||||
});
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps prompting when diagnostic context collection fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext: () => {
|
||||
throw new Error("diagnostics unavailable");
|
||||
},
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} });
|
||||
});
|
||||
|
||||
it("keeps dev diagnostics non-prompting", async () => {
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "reload" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, { isDev: true, showReloadPrompt, log: vi.fn() });
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(showReloadPrompt).not.toHaveBeenCalled();
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows actionable recovery guidance before diagnostic details", async () => {
|
||||
let detail = "";
|
||||
const showMessageBox = vi.fn(
|
||||
async (options: { title: string; message: string; detail: string }) => {
|
||||
detail = options.detail;
|
||||
return { response: 1 };
|
||||
},
|
||||
);
|
||||
const showReloadPrompt = createElectronReloadPrompt(showMessageBox);
|
||||
|
||||
await showReloadPrompt({ kind: "unresponsive", context: {} });
|
||||
|
||||
expect(showMessageBox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Multica needs to reload",
|
||||
message: "The desktop window has been stuck for a few seconds.",
|
||||
detail: expect.stringContaining(
|
||||
"Click Reload to refresh this window and keep using Multica.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(detail).toContain("what you were doing right before this message appeared");
|
||||
expect(detail).toContain("Activity Monitor sample");
|
||||
expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("freeze/crash breadcrumb state machine", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function install(fixture: ReturnType<typeof makeWindow>) {
|
||||
const persistBreadcrumb = vi.fn();
|
||||
const clearBreadcrumb = vi.fn();
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt: vi.fn(async () => "dismiss" as const),
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
return { persistBreadcrumb, clearBreadcrumb };
|
||||
}
|
||||
|
||||
it("a sustained hang writes exactly one unresponsive breadcrumb", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "unresponsive" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovering after a written breadcrumb clears it (no double-count, no false recovered:false)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
expect(clearBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recovering before the delay never writes a breadcrumb, so nothing to clear", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a hang that never recovers (force-quit) keeps its breadcrumb for next-boot reporting", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// No "responsive" ever fires — the breadcrumb must survive uncleared.
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a recoverable crash writes a breadcrumb and never clears it (a dead process never recovers)", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "render-process-gone" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a clean (non-crash) renderer exit writes no breadcrumb", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
export type RendererRecoveryWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
on: (event: "unresponsive" | "responsive", handler: () => void) => unknown;
|
||||
webContents: {
|
||||
on: (event: string, handler: (...args: any[]) => void) => unknown;
|
||||
reload: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ReloadPromptPayload = {
|
||||
kind: "render-process-gone" | "preload-error" | "unresponsive";
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ReloadPromptResult = "reload" | "dismiss";
|
||||
|
||||
type RendererRecoveryOptions = {
|
||||
isDev: boolean;
|
||||
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
|
||||
getDiagnosticContext?: () => Record<string, unknown>;
|
||||
/**
|
||||
* Persist a freeze/crash breadcrumb to disk. The renderer can't report a
|
||||
* true hang or process death itself (blocked / gone), so the main process
|
||||
* writes it here and the next renderer boot flushes it to telemetry. Omit
|
||||
* in dev to keep field telemetry clean.
|
||||
*/
|
||||
persistBreadcrumb?: (payload: ReloadPromptPayload) => void;
|
||||
/**
|
||||
* Delete a previously-persisted unresponsive breadcrumb. Called when the
|
||||
* renderer recovers (`responsive` after `unresponsive`): the window came
|
||||
* back, so the in-thread watchdog reports the freeze and the breadcrumb
|
||||
* would only double-count it. Crash breadcrumbs are never cleared — a dead
|
||||
* process never recovers.
|
||||
*/
|
||||
clearBreadcrumb?: () => void;
|
||||
log?: (tag: string, ...args: unknown[]) => void;
|
||||
unresponsivePromptDelayMs?: number;
|
||||
};
|
||||
|
||||
export function installRendererRecoveryHandlers(
|
||||
window: RendererRecoveryWindow,
|
||||
{
|
||||
isDev,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext,
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
log = defaultDevLog,
|
||||
unresponsivePromptDelayMs = 1500,
|
||||
}: RendererRecoveryOptions,
|
||||
) {
|
||||
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// True once a breadcrumb has been written for the current hang. A later
|
||||
// `responsive` clears it; only a hang that never returns survives to report.
|
||||
let unresponsiveBreadcrumbWritten = false;
|
||||
const mergeDiagnosticContext = (context: Record<string, unknown>) => ({
|
||||
...readDiagnosticContext(getDiagnosticContext),
|
||||
...context,
|
||||
});
|
||||
const maybePromptReload = (payload: ReloadPromptPayload) => {
|
||||
if (isDev) return;
|
||||
void showReloadPrompt(payload).then((result) => {
|
||||
if (result === "reload" && !window.isDestroyed()) {
|
||||
window.webContents.reload();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (isDev) log("process-gone", JSON.stringify(details));
|
||||
if (!isRecoverableRendererExit(details)) return;
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "render-process-gone",
|
||||
context: mergeDiagnosticContext({ details }),
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
maybePromptReload(payload);
|
||||
});
|
||||
|
||||
// preload-error intentionally does NOT persist a breadcrumb: it's a startup
|
||||
// failure of the preload script itself, and the breadcrumb-flush path depends
|
||||
// on that same preload exposing `getLastFreeze` — if preload is broken, the
|
||||
// next boot couldn't read it back anyway. We only prompt for reload here.
|
||||
window.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
|
||||
maybePromptReload({
|
||||
kind: "preload-error",
|
||||
context: mergeDiagnosticContext({ preloadPath, error: formatError(error) }),
|
||||
});
|
||||
});
|
||||
|
||||
window.on("unresponsive", () => {
|
||||
if (isDev || unresponsivePromptTimer) return;
|
||||
unresponsivePromptTimer = setTimeout(() => {
|
||||
unresponsivePromptTimer = null;
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "unresponsive",
|
||||
context: mergeDiagnosticContext({}),
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
unresponsiveBreadcrumbWritten = true;
|
||||
maybePromptReload(payload);
|
||||
}, unresponsivePromptDelayMs);
|
||||
});
|
||||
|
||||
window.on("responsive", () => {
|
||||
if (unresponsivePromptTimer) {
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
}
|
||||
// The window came back: drop any breadcrumb written during this hang so it
|
||||
// isn't re-reported (and mislabeled `recovered: false`) on next boot.
|
||||
if (unresponsiveBreadcrumbWritten) {
|
||||
clearBreadcrumb?.();
|
||||
unresponsiveBreadcrumbWritten = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createElectronReloadPrompt(
|
||||
showMessageBox: (options: {
|
||||
type: "warning";
|
||||
buttons: string[];
|
||||
defaultId: number;
|
||||
cancelId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
detail: string;
|
||||
}) => Promise<{ response: number }>,
|
||||
) {
|
||||
return async (payload: ReloadPromptPayload): Promise<ReloadPromptResult> => {
|
||||
const result = await showMessageBox({
|
||||
type: "warning",
|
||||
buttons: ["Reload", "Dismiss"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: "Multica needs to reload",
|
||||
message: rendererRecoveryMessage(payload.kind),
|
||||
detail: rendererRecoveryDetail(payload),
|
||||
});
|
||||
return result.response === 0 ? "reload" : "dismiss";
|
||||
};
|
||||
}
|
||||
|
||||
function isRecoverableRendererExit(details: unknown) {
|
||||
if (!details || typeof details !== "object") return false;
|
||||
const reason = (details as { reason?: unknown }).reason;
|
||||
return (
|
||||
reason === "crashed" ||
|
||||
reason === "oom" ||
|
||||
reason === "abnormal-exit" ||
|
||||
reason === "launch-failed" ||
|
||||
reason === "integrity-failure"
|
||||
);
|
||||
}
|
||||
|
||||
function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) {
|
||||
switch (kind) {
|
||||
case "render-process-gone":
|
||||
return "The desktop window stopped unexpectedly.";
|
||||
case "preload-error":
|
||||
return "The desktop window could not finish starting.";
|
||||
case "unresponsive":
|
||||
return "The desktop window has been stuck for a few seconds.";
|
||||
}
|
||||
}
|
||||
|
||||
function rendererRecoveryDetail(payload: ReloadPromptPayload) {
|
||||
const guidance = [
|
||||
"Click Reload to refresh this window and keep using Multica.",
|
||||
"If this keeps happening, please tell us what you were doing right before this message appeared and whether Reload recovered the window.",
|
||||
];
|
||||
|
||||
if (payload.kind === "unresponsive") {
|
||||
guidance.push(
|
||||
"For macOS reports, an Activity Monitor sample of the Multica Helper (Renderer) process helps us find what blocked the app.",
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
...guidance,
|
||||
"",
|
||||
"Diagnostic details:",
|
||||
`kind: ${payload.kind}`,
|
||||
`context: ${JSON.stringify(payload.context)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function defaultDevLog(tag: string, ...args: unknown[]) {
|
||||
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
|
||||
}
|
||||
|
||||
function readDiagnosticContext(
|
||||
getDiagnosticContext: (() => Record<string, unknown>) | undefined,
|
||||
) {
|
||||
if (!getDiagnosticContext) return {};
|
||||
try {
|
||||
return getDiagnosticContext();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown) {
|
||||
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BrowserWindow, WebContents } from "electron";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
const ctx = vi.hoisted(() => ({
|
||||
handlers: new Map<string, Handler[]>(),
|
||||
ipcHandle: vi.fn(),
|
||||
checkForUpdates: vi.fn(async () => ({
|
||||
updateInfo: { version: "0.3.18" },
|
||||
isUpdateAvailable: false,
|
||||
})),
|
||||
downloadUpdate: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
getVersion: vi.fn(() => "0.3.17"),
|
||||
}));
|
||||
|
||||
vi.mock("electron-updater", () => {
|
||||
const autoUpdater = {
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
channel: undefined as string | undefined,
|
||||
on: vi.fn((event: string, handler: Handler) => {
|
||||
const handlers = ctx.handlers.get(event) ?? [];
|
||||
handlers.push(handler);
|
||||
ctx.handlers.set(event, handlers);
|
||||
return autoUpdater;
|
||||
}),
|
||||
checkForUpdates: ctx.checkForUpdates,
|
||||
downloadUpdate: ctx.downloadUpdate,
|
||||
quitAndInstall: ctx.quitAndInstall,
|
||||
};
|
||||
return { autoUpdater };
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getVersion: ctx.getVersion,
|
||||
},
|
||||
BrowserWindow: class BrowserWindow {},
|
||||
ipcMain: {
|
||||
handle: ctx.ipcHandle,
|
||||
},
|
||||
}));
|
||||
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
|
||||
function emitUpdater(event: string, ...args: unknown[]) {
|
||||
for (const handler of ctx.handlers.get(event) ?? []) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
|
||||
function makeWindow() {
|
||||
const send = vi.fn();
|
||||
return {
|
||||
win: {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
send,
|
||||
},
|
||||
} as unknown as BrowserWindow,
|
||||
send,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDestroyedWindow() {
|
||||
return {
|
||||
isDestroyed: () => true,
|
||||
get webContents(): WebContents {
|
||||
throw new TypeError("Object has been destroyed");
|
||||
},
|
||||
} as unknown as BrowserWindow;
|
||||
}
|
||||
|
||||
function makeWindowWithDestroyedWebContents() {
|
||||
const send = vi.fn(() => {
|
||||
throw new TypeError("Object has been destroyed");
|
||||
});
|
||||
return {
|
||||
win: {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => true,
|
||||
send,
|
||||
},
|
||||
} as unknown as BrowserWindow,
|
||||
send,
|
||||
};
|
||||
}
|
||||
|
||||
function makeWindowWithThrowingSend(error: Error) {
|
||||
const send = vi.fn(() => {
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
win: {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
send,
|
||||
},
|
||||
} as unknown as BrowserWindow,
|
||||
send,
|
||||
};
|
||||
}
|
||||
|
||||
describe("setupAutoUpdater", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
ctx.handlers.clear();
|
||||
ctx.ipcHandle.mockClear();
|
||||
ctx.checkForUpdates.mockClear();
|
||||
ctx.downloadUpdate.mockClear();
|
||||
ctx.quitAndInstall.mockClear();
|
||||
ctx.getVersion.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("forwards update progress to a live renderer", () => {
|
||||
const { win, send } = makeWindow();
|
||||
setupAutoUpdater(() => win);
|
||||
|
||||
emitUpdater("download-progress", { percent: 42 });
|
||||
|
||||
expect(send).toHaveBeenCalledWith("updater:download-progress", {
|
||||
percent: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips update progress when the BrowserWindow has already been destroyed", () => {
|
||||
setupAutoUpdater(() => makeDestroyedWindow());
|
||||
|
||||
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
|
||||
});
|
||||
|
||||
it("skips update progress when the BrowserWindow webContents has already been destroyed", () => {
|
||||
const { win, send } = makeWindowWithDestroyedWebContents();
|
||||
setupAutoUpdater(() => win);
|
||||
|
||||
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips update progress when webContents.send loses a destroy race", () => {
|
||||
const { win, send } = makeWindowWithThrowingSend(
|
||||
new TypeError("Object has been destroyed"),
|
||||
);
|
||||
setupAutoUpdater(() => win);
|
||||
|
||||
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
|
||||
expect(send).toHaveBeenCalledWith("updater:download-progress", {
|
||||
percent: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("rethrows non-destroy errors from webContents.send", () => {
|
||||
const { win } = makeWindowWithThrowingSend(new Error("boom"));
|
||||
setupAutoUpdater(() => win);
|
||||
|
||||
expect(() => emitUpdater("download-progress", { percent: 42 })).toThrow(
|
||||
"boom",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { autoUpdater, type UpdateDownloadedEvent } from "electron-updater";
|
||||
import { app, type BrowserWindow, ipcMain } from "electron";
|
||||
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
// Silent background updates: electron-updater downloads on its own as soon
|
||||
// as `update-available` fires; we only surface UI when the package is fully
|
||||
@@ -29,32 +29,6 @@ export type ManualUpdateCheckResult =
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
type RendererChannel =
|
||||
| "updater:update-available"
|
||||
| "updater:download-progress"
|
||||
| "updater:update-downloaded";
|
||||
|
||||
function isDestroyedObjectError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message.includes("Object has been destroyed");
|
||||
}
|
||||
|
||||
function sendToLiveRenderer(
|
||||
win: BrowserWindow | null,
|
||||
channel: RendererChannel,
|
||||
payload: unknown,
|
||||
): void {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
|
||||
try {
|
||||
const { webContents } = win;
|
||||
if (webContents.isDestroyed()) return;
|
||||
webContents.send(channel, payload);
|
||||
} catch (err) {
|
||||
if (isDestroyedObjectError(err)) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-flight guard around checkForUpdates(). With autoDownload=true the
|
||||
// startup, periodic, and manual triggers can all kick off downloads, and
|
||||
// overlapping calls have caused duplicate download warnings in the past
|
||||
@@ -88,20 +62,23 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
// Forwarded for renderer-side state tracking only; the notification UI
|
||||
// does not render an "available" affordance with autoDownload=true.
|
||||
sendToLiveRenderer(getMainWindow(), "updater:update-available", {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
sendToLiveRenderer(getMainWindow(), "updater:download-progress", {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:download-progress", {
|
||||
percent: progress.percent,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
|
||||
sendToLiveRenderer(getMainWindow(), "updater:update-downloaded", {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
|
||||
30
apps/desktop/src/preload/index.d.ts
vendored
30
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,8 +1,6 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { NavigationGesture } from "../shared/navigation-gestures";
|
||||
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -16,9 +14,6 @@ interface DesktopAPI {
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** Read + clear any freeze/crash breadcrumb from a previous session, so the
|
||||
* renderer can flush it to telemetry on boot. Null when nothing's pending. */
|
||||
getLastFreeze: () => FreezeBreadcrumb | null;
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
@@ -50,8 +45,6 @@ interface DesktopAPI {
|
||||
) => () => void;
|
||||
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
|
||||
/** Report the renderer's memory-router path for recovery diagnostics. */
|
||||
setRendererRouteContext: (context: RendererRouteContextInput) => void;
|
||||
/** Open the OS folder picker and return the chosen absolute path.
|
||||
* Used by the Project settings "Add local directory" flow. */
|
||||
pickDirectory: (
|
||||
@@ -78,22 +71,10 @@ interface DesktopAPI {
|
||||
| "error";
|
||||
error?: string;
|
||||
}>;
|
||||
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
|
||||
* Returns an unsubscribe function. */
|
||||
onCloseActiveTab: (callback: () => void) => () => void;
|
||||
/** Ask the main process to close the window. */
|
||||
closeWindow: () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
state:
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "stopping"
|
||||
| "installing_cli"
|
||||
| "cli_not_found"
|
||||
| "auth_expired";
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
@@ -109,11 +90,6 @@ interface DaemonPrefs {
|
||||
autoStop: boolean;
|
||||
}
|
||||
|
||||
type DaemonReauthResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: "session_invalid" }
|
||||
| { ok: false; reason: "transient"; message: string };
|
||||
|
||||
interface DaemonAPI {
|
||||
start: () => Promise<{ success: boolean; error?: string }>;
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
@@ -124,10 +100,6 @@ interface DaemonAPI {
|
||||
setTargetApiUrl: (url: string) => Promise<void>;
|
||||
syncToken: (token: string, userId: string) => Promise<void>;
|
||||
clearToken: () => Promise<void>;
|
||||
reauthenticate: (
|
||||
token: string,
|
||||
userId: string,
|
||||
) => Promise<DaemonReauthResult>;
|
||||
isCliInstalled: () => Promise<boolean>;
|
||||
getPrefs: () => Promise<DaemonPrefs>;
|
||||
setPrefs: (prefs: Partial<DaemonPrefs>) => Promise<DaemonPrefs>;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
type RendererRouteContextInput,
|
||||
} from "../shared/renderer-route-context";
|
||||
import {
|
||||
isNavigationGesture,
|
||||
NAVIGATION_GESTURE_CHANNEL,
|
||||
@@ -79,16 +74,6 @@ const desktopAPI = {
|
||||
},
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Read + clear any freeze/crash breadcrumb left by a previous session, so
|
||||
* the renderer can flush it to telemetry on boot. Returns null when there's
|
||||
* nothing pending (the normal case). */
|
||||
getLastFreeze: (): FreezeBreadcrumb | null => {
|
||||
try {
|
||||
return ipcRenderer.sendSync("freeze:get-last") as FreezeBreadcrumb | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
@@ -171,38 +156,16 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
/** Report the renderer's memory-router path for recovery diagnostics. */
|
||||
setRendererRouteContext: (context: RendererRouteContextInput) =>
|
||||
ipcRenderer.send(RENDERER_ROUTE_CONTEXT_CHANNEL, context),
|
||||
/** Open the OS folder picker and return the chosen absolute path. */
|
||||
pickDirectory: (defaultPath?: string) =>
|
||||
ipcRenderer.invoke("local-directory:pick", defaultPath),
|
||||
/** Validate that a path is an existing readable+writable directory. */
|
||||
validateLocalDirectory: (path: string) =>
|
||||
ipcRenderer.invoke("local-directory:validate", path),
|
||||
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
|
||||
* The renderer should close the active tab; if it was the last tab,
|
||||
* call `closeWindow()` to dismiss the window. Returns an unsubscribe fn. */
|
||||
onCloseActiveTab: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("tab:close-active", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("tab:close-active", handler);
|
||||
};
|
||||
},
|
||||
/** Ask the main process to close the window (used after closing the last tab). */
|
||||
closeWindow: () => ipcRenderer.send("window:close"),
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
state:
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "stopping"
|
||||
| "installing_cli"
|
||||
| "cli_not_found"
|
||||
| "auth_expired";
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
@@ -213,11 +176,6 @@ interface DaemonStatus {
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
type DaemonReauthResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: "session_invalid" }
|
||||
| { ok: false; reason: "transient"; message: string };
|
||||
|
||||
const daemonAPI = {
|
||||
start: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:start"),
|
||||
@@ -240,11 +198,6 @@ const daemonAPI = {
|
||||
ipcRenderer.invoke("daemon:sync-token", token, userId),
|
||||
clearToken: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:clear-token"),
|
||||
reauthenticate: (
|
||||
token: string,
|
||||
userId: string,
|
||||
): Promise<DaemonReauthResult> =>
|
||||
ipcRenderer.invoke("daemon:reauthenticate", token, userId),
|
||||
isCliInstalled: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("daemon:is-cli-installed"),
|
||||
getPrefs: (): Promise<{ autoStart: boolean; autoStop: boolean }> =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { pickLocale, type SupportedLocale } from "@multica/core/i18n";
|
||||
import { pickLocale } from "@multica/core/i18n";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWelcomeStore } from "@multica/core/onboarding";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
@@ -19,58 +19,13 @@ import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
|
||||
import { captureEvent } from "@multica/core/analytics";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
|
||||
// BCP-47 region tags for the <html lang> attribute, mirroring
|
||||
// apps/web/app/layout.tsx HTML_LANG. index.html ships a static lang="en";
|
||||
// we sync it to the resolved locale at boot so screen readers announce the
|
||||
// right language AND the Japanese-scoped CJK font override in globals.css
|
||||
// (`html[lang|="ja"]`) can take effect.
|
||||
const HTML_LANG: Record<SupportedLocale, string> = {
|
||||
en: "en",
|
||||
"zh-Hans": "zh-CN",
|
||||
ko: "ko-KR",
|
||||
ja: "ja-JP",
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl+W: close the active tab. When the last real tab is closed
|
||||
* (or no tabs/workspace exist — e.g. login page), close the window.
|
||||
*
|
||||
* Mounted at the App root so every renderer state — including login,
|
||||
* loading, onboarding, and runtime-config errors — has a working Cmd+W
|
||||
* handler. Without this, states outside the tab shell would swallow the
|
||||
* shortcut and do nothing.
|
||||
*/
|
||||
function useCmdWCloseTab() {
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onCloseActiveTab(() => {
|
||||
const store = useTabStore.getState();
|
||||
const { activeWorkspaceSlug, byWorkspace } = store;
|
||||
if (!activeWorkspaceSlug) {
|
||||
// No workspace — nothing to close, dismiss the window.
|
||||
window.desktopAPI.closeWindow();
|
||||
return;
|
||||
}
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group || group.tabs.length <= 1) {
|
||||
// Last tab (or no tabs) — close the window.
|
||||
window.desktopAPI.closeWindow();
|
||||
return;
|
||||
}
|
||||
// Multiple tabs — close the active one.
|
||||
store.closeActiveTab();
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
|
||||
// setQueryData sequentially. loginWithToken sets user+isLoading=false
|
||||
// as soon as getMe resolves, which would cause DesktopShell to mount
|
||||
@@ -224,7 +179,6 @@ function AppContent() {
|
||||
return undefined;
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
|
||||
|
||||
|
||||
// Validate persisted tab state against the current user's workspace list,
|
||||
// and pick an active workspace if none is set. Runs in useLayoutEffect
|
||||
// (synchronously after render, before paint) rather than the render
|
||||
@@ -331,28 +285,6 @@ export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const systemLocale = window.desktopAPI.systemLocale;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
useCmdWCloseTab();
|
||||
|
||||
// Flush a freeze/crash breadcrumb the main process parked from a previous
|
||||
// session. A true hang or process death can't report itself when it happens
|
||||
// (the renderer is blocked or gone), so the main process persists it and we
|
||||
// emit it here on the next boot. The in-thread, recoverable freeze tier is
|
||||
// handled separately by the shared watchdog in CoreProvider.
|
||||
useEffect(() => {
|
||||
const last = window.desktopAPI.getLastFreeze();
|
||||
if (!last) return;
|
||||
const crashed = last.kind === "render-process-gone";
|
||||
captureEvent(crashed ? "client_crash" : "client_unresponsive", {
|
||||
// Spread context FIRST so our explicit fields below always win — a
|
||||
// future context key (e.g. its own `source`) must not silently override.
|
||||
...last.context,
|
||||
source: crashed ? "render-process-gone" : "main-unresponsive",
|
||||
recovered: false,
|
||||
breadcrumb_ts: last.ts,
|
||||
crashed_version: last.version,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
@@ -371,15 +303,6 @@ export default function App() {
|
||||
[locale],
|
||||
);
|
||||
|
||||
// Keep <html lang> in sync with the resolved locale (index.html hardcodes
|
||||
// "en"). Drives the lang-scoped Japanese CJK font override and a11y.
|
||||
// useLayoutEffect (not useEffect) so lang is committed before the first
|
||||
// paint — otherwise Japanese users would see one frame of Kanji rendered
|
||||
// with the Chinese-first fallback stack before the override kicks in.
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.lang = HTML_LANG[locale];
|
||||
}, [locale]);
|
||||
|
||||
// React to OS-level language changes detected by main on focus regain.
|
||||
// Only act when the user is following the system signal (no explicit
|
||||
// Settings choice) — otherwise their preference wins. Cross-device sync
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { copyText } from "@multica/ui/lib/clipboard";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -195,12 +194,15 @@ export function DaemonPanel({
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = filtered.map((l) => l.raw).join("\n");
|
||||
if (await copyText(text)) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} else {
|
||||
toast.error("Failed to copy");
|
||||
} catch (err) {
|
||||
toast.error("Failed to copy", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, [filtered]);
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
// The component only needs these to render; stub them so the test focuses on
|
||||
// the externally-managed branching, not data fetching.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
vi.mock("@multica/core/runtimes", () => ({
|
||||
runtimeListOptions: () => ({ queryKey: ["runtimes"] }),
|
||||
}));
|
||||
vi.mock("@multica/core/agents", () => ({
|
||||
agentTaskSnapshotOptions: () => ({ queryKey: ["snapshot"] }),
|
||||
}));
|
||||
vi.mock("./daemon-panel", () => ({ DaemonPanel: () => null }));
|
||||
vi.mock("../platform/daemon-reauth", () => ({
|
||||
reauthenticateDaemon: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import { DaemonRuntimeActions } from "./daemon-runtime-card";
|
||||
|
||||
function stubDaemonAPI(status: DaemonStatus) {
|
||||
Object.defineProperty(window, "daemonAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getStatus: vi.fn().mockResolvedValue(status),
|
||||
onStatusChange: vi.fn(() => () => {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("DaemonRuntimeActions — externally managed daemon (#3916)", () => {
|
||||
it("hides Stop/Restart and shows the managed-outside hint for a daemon the app can't control", async () => {
|
||||
stubDaemonAPI({ state: "running", daemonId: "d1", externallyManaged: true });
|
||||
render(<DaemonRuntimeActions />);
|
||||
|
||||
// View logs still renders, confirming the running branch mounted.
|
||||
expect(await screen.findByText("View logs")).toBeInTheDocument();
|
||||
expect(screen.getByText("Managed outside the app")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Restart")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Stop")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Stop/Restart for a normally-managed running daemon (no 误伤)", async () => {
|
||||
stubDaemonAPI({
|
||||
state: "running",
|
||||
daemonId: "d1",
|
||||
externallyManaged: false,
|
||||
});
|
||||
render(<DaemonRuntimeActions />);
|
||||
|
||||
expect(await screen.findByText("Restart")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Managed outside the app"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
RotateCw,
|
||||
Activity,
|
||||
ScrollText,
|
||||
LogIn,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -24,7 +22,6 @@ import {
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import { reauthenticateDaemon } from "../platform/daemon-reauth";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
@@ -118,24 +115,9 @@ export function DaemonRuntimeActions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleReauth = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
await reauthenticateDaemon();
|
||||
// onStatusChange resets actionLoading on the next status push; reset here
|
||||
// too in case reauth logged out (unmount) or produced no status change.
|
||||
setActionLoading(false);
|
||||
}, []);
|
||||
|
||||
const isRunning = status.state === "running";
|
||||
// The daemon runs somewhere the app can't drive (e.g. inside WSL2): the
|
||||
// lifecycle CLI acts on the host process namespace and can't reach it. Hide
|
||||
// Stop/Restart so they don't silently no-op, mirroring the Settings tab. The
|
||||
// real guard is in the main process (stopDaemon/restartDaemon); this is the
|
||||
// matching UX. See #3916.
|
||||
const externallyManaged = status.externallyManaged === true;
|
||||
const isStopped = status.state === "stopped";
|
||||
const isCliMissing = status.state === "cli_not_found";
|
||||
const isAuthExpired = status.state === "auth_expired";
|
||||
const isTransitioning =
|
||||
status.state === "starting" || status.state === "stopping";
|
||||
const isInstalling = status.state === "installing_cli";
|
||||
@@ -149,33 +131,24 @@ export function DaemonRuntimeActions() {
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
{externallyManaged ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Info className="size-3.5 shrink-0" />
|
||||
Managed outside the app
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -202,23 +175,6 @@ export function DaemonRuntimeActions() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAuthExpired && (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
Sign-in expired
|
||||
</span>
|
||||
<Button size="sm" onClick={handleReauth} disabled={actionLoading}>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<LogIn className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Sign in again
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { AlertCircle, Info, LogIn } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { reauthenticateDaemon } from "../platform/daemon-reauth";
|
||||
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
@@ -63,7 +61,6 @@ export function DaemonSettingsTab() {
|
||||
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [reauthLoading, setReauthLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
@@ -72,12 +69,6 @@ export function DaemonSettingsTab() {
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const handleReauth = useCallback(async () => {
|
||||
setReauthLoading(true);
|
||||
await reauthenticateDaemon();
|
||||
setReauthLoading(false);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
async (key: keyof DaemonPrefs, value: boolean) => {
|
||||
setSaving(true);
|
||||
@@ -88,12 +79,6 @@ export function DaemonSettingsTab() {
|
||||
[],
|
||||
);
|
||||
|
||||
// The daemon runs somewhere the app can't drive (e.g. inside WSL2 behind a
|
||||
// Windows desktop): /health is reachable but the lifecycle CLI can't reach
|
||||
// its process. Auto-start/auto-stop can't work, so disable them and say why
|
||||
// rather than letting the toggles silently no-op. See #3916.
|
||||
const externallyManaged = status.externallyManaged === true;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Daemon</h2>
|
||||
@@ -101,43 +86,6 @@ export function DaemonSettingsTab() {
|
||||
Configure how the local agent daemon behaves with the desktop app.
|
||||
</p>
|
||||
|
||||
{status.state === "auth_expired" && (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Sign-in expired
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
The local daemon couldn't authenticate, so this device
|
||||
can't take tasks. Sign in again to restore it.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={handleReauth}
|
||||
disabled={reauthLoading}
|
||||
>
|
||||
<LogIn className="size-3.5 mr-1.5" />
|
||||
Sign in again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{externallyManaged && (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-lg border bg-muted/30 px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="min-w-0 text-sm text-muted-foreground">
|
||||
This device's daemon runs outside the app — for example inside
|
||||
WSL2 — so the app can't start or stop it. Start or stop it from
|
||||
that environment with{" "}
|
||||
<code className="font-mono text-xs">multica daemon start</code> /{" "}
|
||||
<code className="font-mono text-xs">multica daemon stop</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<SettingRow
|
||||
label="Auto-start on launch"
|
||||
@@ -146,7 +94,7 @@ export function DaemonSettingsTab() {
|
||||
<Switch
|
||||
checked={prefs.autoStart}
|
||||
onCheckedChange={(checked) => updatePref("autoStart", checked)}
|
||||
disabled={saving || externallyManaged}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -157,7 +105,7 @@ export function DaemonSettingsTab() {
|
||||
<Switch
|
||||
checked={prefs.autoStop}
|
||||
onCheckedChange={(checked) => updatePref("autoStop", checked)}
|
||||
disabled={saving || externallyManaged}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabHistory } from "@/hooks/use-tab-history";
|
||||
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
|
||||
@@ -15,7 +14,6 @@ import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { useDesktopUnreadBadge } from "@multica/views/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
@@ -23,69 +21,41 @@ import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
import { WindowOverlay } from "./window-overlay";
|
||||
|
||||
const TOP_BAR_HEIGHT_CLASS = "h-12";
|
||||
const WINDOW_TOOLBAR_CLEARANCE = 184;
|
||||
const toolbarMotion = {
|
||||
type: "spring",
|
||||
stiffness: 420,
|
||||
damping: 38,
|
||||
mass: 0.8,
|
||||
} as const;
|
||||
|
||||
function WindowToolbar() {
|
||||
function SidebarTopBar() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
|
||||
const navButtonClassName =
|
||||
"flex size-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-30";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-30 flex w-[184px] shrink-0 items-center px-3",
|
||||
TOP_BAR_HEIGHT_CLASS,
|
||||
)}
|
||||
className="h-12 shrink-0 flex items-center justify-end px-2"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 pl-[70px]"
|
||||
className="flex items-center gap-0.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<SidebarTrigger
|
||||
className="size-7 text-muted-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Go back"
|
||||
title="Go back"
|
||||
className={navButtonClassName}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Go forward"
|
||||
title="Go forward"
|
||||
className={navButtonClassName}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Go back"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Go forward"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTopSpacer() {
|
||||
return <div className={cn("shrink-0", TOP_BAR_HEIGHT_CLASS)} />;
|
||||
}
|
||||
|
||||
function useNativeNavigationGestures() {
|
||||
const { goBack, goForward } = useTabHistory();
|
||||
|
||||
@@ -101,31 +71,30 @@ function useNativeNavigationGestures() {
|
||||
}
|
||||
|
||||
// The main area's top bar doubles as a window drag region. When the sidebar
|
||||
// is not occupying main-flow width, leave room for the fixed window toolbar
|
||||
// so tabs do not land beneath the traffic lights / navigation controls.
|
||||
// is not occupying main-flow width — either user-collapsed (offcanvas) or
|
||||
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
|
||||
// left side so tabs don't land under the macOS traffic lights (which live at
|
||||
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
|
||||
// the sidebar can be brought back without keyboard shortcut.
|
||||
function MainTopBar() {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const sidebarHidden = state === "collapsed" || isMobile;
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
animate={{ paddingLeft: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
|
||||
className={cn("relative shrink-0 flex items-center gap-2", TOP_BAR_HEIGHT_CLASS)}
|
||||
initial={false}
|
||||
transition={toolbarMotion}
|
||||
<header
|
||||
className={cn(
|
||||
"h-12 shrink-0 flex items-center gap-2",
|
||||
sidebarHidden && "pl-20",
|
||||
)}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<motion.div
|
||||
aria-hidden
|
||||
animate={{ left: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
|
||||
className="absolute inset-y-0 right-0"
|
||||
initial={false}
|
||||
transition={toolbarMotion}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
<div className="relative z-10 flex h-full items-center">
|
||||
<TabBar />
|
||||
</div>
|
||||
</motion.header>
|
||||
{sidebarHidden && (
|
||||
<SidebarTrigger
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
/>
|
||||
)}
|
||||
<TabBar />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,30 +127,18 @@ function useInternalLinkHandler() {
|
||||
* inbox even if the user has since switched to workspace B. Marking
|
||||
* the row read is handled by InboxPage's selected-item effect, which
|
||||
* covers both click-to-select and URL-param-select paths.
|
||||
*
|
||||
* The click routes through `useNavigation().push` — NOT the
|
||||
* `multica:navigate` event, whose handler `openTab`s into the ACTIVE
|
||||
* workspace's tab group. The navigation adapter detects a cross-workspace
|
||||
* path and translates it into `switchWorkspace(slug, path)`, so clicking a
|
||||
* workspace-A notification while B is active performs a real workspace
|
||||
* switch instead of mounting A's inbox inside B's tab group (#3766).
|
||||
*/
|
||||
function DesktopInboxBridge() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
useDesktopUnreadBadge(workspace?.id ?? null);
|
||||
const { push } = useNavigation();
|
||||
// The adapter identity changes with the active tab's location; the ref
|
||||
// keeps the main-process subscription stable across navigations.
|
||||
const pushRef = useRef(push);
|
||||
useEffect(() => {
|
||||
pushRef.current = push;
|
||||
}, [push]);
|
||||
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
|
||||
if (!slug) return;
|
||||
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
|
||||
pushRef.current(inboxPath);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -213,10 +170,9 @@ export function DesktopShell() {
|
||||
<DesktopInboxBridge />
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <WindowToolbar />}
|
||||
{slug && <AppSidebar topSlot={<SidebarTopSpacer />} searchSlot={<SearchTrigger />} />}
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
{/* Right side: header + content container */}
|
||||
<motion.div layout transition={toolbarMotion} className="flex flex-1 min-w-0 flex-col">
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
@@ -224,7 +180,7 @@ export function DesktopShell() {
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useTabStore,
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
import type { RendererRouteContextInput } from "../../../shared/renderer-route-context";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes,
|
||||
@@ -91,16 +90,6 @@ export function PageviewTracker() {
|
||||
const last = lastSurfaceRef.current;
|
||||
const next = { kind, key, path };
|
||||
|
||||
const routeContext: RendererRouteContextInput = {
|
||||
surface: kind,
|
||||
path,
|
||||
};
|
||||
if (kind === "tab") {
|
||||
routeContext.workspaceSlug = activeWorkspaceSlug ?? undefined;
|
||||
routeContext.tabId = activeTabId ?? undefined;
|
||||
}
|
||||
reportRendererRouteContext(routeContext);
|
||||
|
||||
if (kind === "tab" && key !== null) {
|
||||
const knownPath = observed.get(key);
|
||||
const isReactivation =
|
||||
@@ -123,13 +112,6 @@ export function PageviewTracker() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function reportRendererRouteContext(context: RendererRouteContextInput) {
|
||||
const desktopAPI = window.desktopAPI as
|
||||
| { setRendererRouteContext?: (context: RendererRouteContextInput) => void }
|
||||
| undefined;
|
||||
desktopAPI?.setRendererRouteContext?.(context);
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router-dom";
|
||||
|
||||
const openModal = vi.fn();
|
||||
const reloadActiveTab = vi.fn();
|
||||
const closeActiveTab = vi.fn();
|
||||
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: {
|
||||
getState: () => ({ open: openModal }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => ({
|
||||
useTabStore: {
|
||||
getState: () => ({ reloadActiveTab, closeActiveTab }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { DesktopRouteErrorPage, formatRouteErrorReport } from "./route-error-page";
|
||||
|
||||
function Boom(): null {
|
||||
throw new Error("route render exploded");
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("DesktopRouteErrorPage", () => {
|
||||
beforeEach(() => {
|
||||
openModal.mockReset();
|
||||
reloadActiveTab.mockReset();
|
||||
closeActiveTab.mockReset();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("brands React Router route errors and offers tab reload", async () => {
|
||||
const router = createMemoryRouter(
|
||||
[{ path: "/", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
|
||||
{ initialEntries: ["/"] },
|
||||
);
|
||||
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Something went wrong in this tab",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /reload tab/i }));
|
||||
expect(reloadActiveTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("offers Close tab as the always-safe escape from a crashing route", async () => {
|
||||
const router = createMemoryRouter(
|
||||
[{ path: "/acme/issues/1", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
|
||||
{ initialEntries: ["/acme/issues/1"] },
|
||||
);
|
||||
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /close tab/i }));
|
||||
expect(closeActiveTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens the existing feedback modal with a structured markdown report only after click", async () => {
|
||||
const router = createMemoryRouter(
|
||||
[{ path: "/acme/issues", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
|
||||
{ initialEntries: ["/acme/issues"] },
|
||||
);
|
||||
|
||||
render(<RouterProvider router={router} />);
|
||||
|
||||
expect(openModal).not.toHaveBeenCalled();
|
||||
fireEvent.click(await screen.findByRole("button", { name: /report error/i }));
|
||||
|
||||
expect(openModal).toHaveBeenCalledWith(
|
||||
"feedback",
|
||||
expect.objectContaining({
|
||||
initialMessage: expect.stringContaining("kind: desktop_route_error"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("documents the structured kind/context follow-up debt in the report template", () => {
|
||||
const report = formatRouteErrorReport({
|
||||
error: new Error("bad route"),
|
||||
url: "app://desktop/acme/issues",
|
||||
appInfo: { version: "1.2.3", os: "macos" },
|
||||
trigger: "route-errorElement",
|
||||
});
|
||||
|
||||
expect(report).toContain("kind: desktop_route_error");
|
||||
expect(report).toContain("trigger: route-errorElement");
|
||||
expect(report).toContain("TODO: promote kind/context to structured feedback fields");
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useLocation, useNavigate, useRouteError } from "react-router-dom";
|
||||
import { AlertTriangle, RotateCw, Send, X } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
type DesktopAppInfo = {
|
||||
version?: string;
|
||||
os?: string;
|
||||
};
|
||||
|
||||
export function formatRouteErrorReport({
|
||||
error,
|
||||
url,
|
||||
appInfo,
|
||||
trigger,
|
||||
}: {
|
||||
error: unknown;
|
||||
url: string;
|
||||
appInfo?: DesktopAppInfo;
|
||||
trigger: string;
|
||||
}) {
|
||||
const normalized = normalizeError(error);
|
||||
return [
|
||||
"kind: desktop_route_error",
|
||||
`trigger: ${trigger}`,
|
||||
`url: ${url}`,
|
||||
`app_version: ${appInfo?.version ?? "unknown"}`,
|
||||
`runtime_os: ${appInfo?.os ?? "unknown"}`,
|
||||
"",
|
||||
"context:",
|
||||
`- name: ${normalized.name}`,
|
||||
`- message: ${normalized.message}`,
|
||||
"",
|
||||
"stack:",
|
||||
"```",
|
||||
normalized.stack ?? "<no stack>",
|
||||
"```",
|
||||
"",
|
||||
"TODO: promote kind/context to structured feedback fields once the feedback API supports them.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function DesktopRouteErrorPage() {
|
||||
const error = useRouteError();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const workspaceSlug = location.pathname.split("/").filter(Boolean)[0];
|
||||
const safeRoute = workspaceSlug ? `/${workspaceSlug}/issues` : null;
|
||||
const report = useMemo(
|
||||
() =>
|
||||
formatRouteErrorReport({
|
||||
error,
|
||||
url:
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.origin}${location.pathname}${location.search}${location.hash}`
|
||||
: location.pathname,
|
||||
appInfo: typeof window !== "undefined" ? window.desktopAPI?.appInfo : undefined,
|
||||
trigger: "route-errorElement",
|
||||
}),
|
||||
[error, location.hash, location.pathname, location.search],
|
||||
);
|
||||
const message = normalizeError(error).message;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex h-full min-h-[20rem] flex-col items-center justify-center gap-4 p-8 text-center"
|
||||
>
|
||||
<div className="rounded-full bg-destructive/10 p-3 text-destructive">
|
||||
<AlertTriangle className="h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">Something went wrong in this tab</h2>
|
||||
<p className="max-w-lg text-sm text-muted-foreground">
|
||||
A route-level renderer error was contained before it could take down the
|
||||
desktop shell. Reload this tab, or send the report if it keeps happening.
|
||||
</p>
|
||||
<p className="max-w-lg truncate text-xs text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => useTabStore.getState().reloadActiveTab()}
|
||||
>
|
||||
<RotateCw className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Reload tab
|
||||
</Button>
|
||||
{safeRoute ? (
|
||||
<Button type="button" variant="outline" onClick={() => navigate(safeRoute, { replace: true })}>
|
||||
Go to issues
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => useTabStore.getState().closeActiveTab()}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Close tab
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
useModalStore.getState().open("feedback", {
|
||||
initialMessage: report,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Report error
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): { name: string; message: string; stack?: string } {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name || "Error",
|
||||
message: error.message || "Unknown route error",
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return { name: "Error", message: error };
|
||||
}
|
||||
return { name: "Error", message: "Unknown route error", stack: safeJson(error) };
|
||||
}
|
||||
|
||||
function safeJson(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
// vi.hoisted shared state for all the stores / hooks the layout consumes.
|
||||
const state = vi.hoisted(() => ({
|
||||
user: null as { id: string } | null,
|
||||
isAuthLoading: false,
|
||||
overlay: null as { type: string } | null,
|
||||
workspace: null as { id: string; slug: string } | null,
|
||||
listFetched: true,
|
||||
wsList: [] as { id: string; slug: string }[],
|
||||
workspaceSeen: true,
|
||||
modalRenders: 0,
|
||||
modalAriaLabel: "source-backfill-modal-marker",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = (selector: (s: typeof state) => unknown) => {
|
||||
if (selector.toString().includes("isLoading"))
|
||||
return state.isAuthLoading;
|
||||
return state.user;
|
||||
};
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/platform", () => ({
|
||||
setCurrentWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace", async () => {
|
||||
const actual = await vi.importActual<typeof import("@multica/core/workspace")>(
|
||||
"@multica/core/workspace",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
workspaceBySlugOptions: () => ({
|
||||
queryKey: ["workspace-by-slug"],
|
||||
queryFn: async () => state.workspace,
|
||||
}),
|
||||
workspaceListOptions: () => ({
|
||||
queryKey: ["workspace-list"],
|
||||
queryFn: async () => state.wsList,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/paths", async () => {
|
||||
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
|
||||
"@multica/core/paths",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
WorkspaceSlugProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
paths: {
|
||||
...actual.paths,
|
||||
login: () => "/login",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/views/workspace/use-workspace-seen", () => ({
|
||||
useWorkspaceSeen: () => state.workspaceSeen,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/views/workspace/welcome-after-onboarding", () => ({
|
||||
WelcomeAfterOnboarding: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/views/layout", () => ({
|
||||
WorkspacePresencePrefetch: () => null,
|
||||
}));
|
||||
|
||||
// The point of this whole test: assert the desktop layout mounts the
|
||||
// SourceBackfillModal. We stub the real component with a marker that
|
||||
// renders only when the layout actually rendered it (and not e.g.
|
||||
// suppressed by overlayActive).
|
||||
vi.mock("@multica/views/onboarding", () => ({
|
||||
SourceBackfillModal: () => {
|
||||
state.modalRenders += 1;
|
||||
return <div data-testid={state.modalAriaLabel} />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => ({
|
||||
useTabStore: Object.assign(() => null, {
|
||||
getState: () => ({ validateWorkspaceSlugs: vi.fn() }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/window-overlay-store", () => {
|
||||
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
|
||||
selector(state);
|
||||
return { useWindowOverlayStore };
|
||||
});
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { WorkspaceRouteLayout } from "./workspace-route-layout";
|
||||
|
||||
function renderLayout() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
// Seed the workspace queries so the gate inside the layout passes
|
||||
// synchronously — the real hook reads from cache.
|
||||
qc.setQueryData(["workspace-by-slug"], state.workspace);
|
||||
qc.setQueryData(["workspace-list"], state.wsList);
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/acme/issues"]}>
|
||||
<Routes>
|
||||
<Route path=":workspaceSlug/*" element={<WorkspaceRouteLayout />}>
|
||||
<Route path="*" element={<div data-testid="outlet" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.user = { id: "u1" };
|
||||
state.isAuthLoading = false;
|
||||
state.overlay = null;
|
||||
state.workspace = { id: "ws-1", slug: "acme" };
|
||||
state.listFetched = true;
|
||||
state.wsList = [{ id: "ws-1", slug: "acme" }];
|
||||
state.workspaceSeen = true;
|
||||
state.modalRenders = 0;
|
||||
});
|
||||
|
||||
describe("WorkspaceRouteLayout", () => {
|
||||
it("mounts SourceBackfillModal when no WindowOverlay is active", () => {
|
||||
const { queryByTestId } = renderLayout();
|
||||
expect(queryByTestId(state.modalAriaLabel)).not.toBeNull();
|
||||
expect(state.modalRenders).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("suppresses SourceBackfillModal while a WindowOverlay is active", () => {
|
||||
state.overlay = { type: "new-workspace" };
|
||||
const { queryByTestId } = renderLayout();
|
||||
expect(queryByTestId(state.modalAriaLabel)).toBeNull();
|
||||
expect(state.modalRenders).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding";
|
||||
import { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { SourceBackfillModal } from "@multica/views/onboarding";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
@@ -105,13 +104,6 @@ export function WorkspaceRouteLayout() {
|
||||
* Modal — unless the store signal has already been consumed, in
|
||||
* which case the hook renders null. */}
|
||||
{!overlayActive && <WelcomeAfterOnboarding />}
|
||||
{/* Source-attribution backfill: same Dialog the web shell mounts
|
||||
* inside DashboardLayout. Desktop's WorkspaceRouteLayout doesn't
|
||||
* wrap DashboardLayout, so the modal has to be wired in directly
|
||||
* here. Same overlay-suppression rule as WelcomeAfterOnboarding —
|
||||
* a portal-rendered Dialog at z-50 would otherwise sit above an
|
||||
* active pre-workspace overlay. */}
|
||||
{!overlayActive && <SourceBackfillModal />}
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const chineseFonts = ["PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"];
|
||||
const koreanFonts = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR"];
|
||||
const japaneseFonts = ["Hiragino Sans", "Yu Gothic", "Noto Sans CJK JP"];
|
||||
|
||||
function expectChineseFontsBeforeKoreanFonts(source: string) {
|
||||
const chineseIndexes = chineseFonts.map((font) => source.indexOf(font));
|
||||
@@ -20,23 +19,6 @@ function expectChineseFontsBeforeKoreanFonts(source: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Japanese Kanji share the Han Unicode block with Chinese, so the global
|
||||
// Chinese-first stack must stay Chinese-first (no zh regression) while a
|
||||
// Japanese-first CJK stack is scoped to html[lang|="ja"]. App.tsx syncs
|
||||
// document.documentElement.lang so the selector matches at runtime.
|
||||
function expectJapaneseScopedOverride(source: string) {
|
||||
expect(source).toContain('html[lang|="ja"]');
|
||||
|
||||
const japaneseIndexes = japaneseFonts.map((font) => source.indexOf(font));
|
||||
expect(japaneseIndexes).not.toContain(-1);
|
||||
|
||||
const firstJapanese = Math.min(...japaneseIndexes);
|
||||
const lastChinese = Math.max(
|
||||
...chineseFonts.map((font) => source.lastIndexOf(font)),
|
||||
);
|
||||
expect(firstJapanese).toBeLessThan(lastChinese);
|
||||
}
|
||||
|
||||
describe("CJK font fallback order", () => {
|
||||
it("keeps desktop Chinese font fallbacks before Korean font fallbacks", () => {
|
||||
const desktopCss = readFileSync(
|
||||
@@ -46,13 +28,4 @@ describe("CJK font fallback order", () => {
|
||||
|
||||
expectChineseFontsBeforeKoreanFonts(desktopCss);
|
||||
});
|
||||
|
||||
it("scopes the Japanese-first CJK stack to html[lang|='ja']", () => {
|
||||
const desktopCss = readFileSync(
|
||||
resolve(process.cwd(), "src/renderer/src/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expectJapaneseScopedOverride(desktopCss);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,26 +31,6 @@
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Japanese-scoped CJK override. Japanese Kanji share the Han Unicode block
|
||||
with Chinese, and CSS font-fallback order is not changed by <html lang> —
|
||||
so the global Chinese-first stack above would give Japanese users Chinese
|
||||
glyph shapes for shared ideographs. We keep the global stack Chinese-first
|
||||
(no regression for zh users) and promote Japanese fonts ahead of the
|
||||
Chinese/Korean families only when the locale is Japanese. App.tsx syncs
|
||||
document.documentElement.lang to the active locale so this selector
|
||||
matches. Mirrors the lang-scoped override in apps/web/app/layout.tsx.
|
||||
`[lang|="ja"]` is the BCP-47 language-range selector: it matches exactly
|
||||
`ja` or `ja-<region>` (App.tsx sets `ja-JP`), never unrelated subtags
|
||||
such as `jam`. */
|
||||
html[lang|="ja"] {
|
||||
--font-sans: "Inter Variable", "Inter", "Hiragino Sans",
|
||||
"Hiragino Kaku Gothic ProN", "Yu Gothic", "YuGothic", "Meiryo",
|
||||
"Noto Sans CJK JP", "Noto Sans JP", -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
|
||||
"Noto Sans CJK SC", "Apple SD Gothic Neo", "Malgun Gothic",
|
||||
"Noto Sans CJK KR", sans-serif;
|
||||
}
|
||||
|
||||
@source "../../../../../packages/ui/**/*.tsx";
|
||||
@source "../../../../../packages/core/**/*.{ts,tsx}";
|
||||
@source "../../../../../packages/views/**/*.{ts,tsx}";
|
||||
|
||||
@@ -13,18 +13,4 @@ import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "./globals.css";
|
||||
|
||||
// react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click any
|
||||
// element to copy its source path + line + component stack for pasting to an AI.
|
||||
// Opt-in per developer: only loads when VITE_REACT_GRAB is set in a local,
|
||||
// gitignored apps/desktop/.env.development.local — it never activates for anyone
|
||||
// else, and the whole branch is tree-shaken out of production builds. The web app
|
||||
// wires the same tool via next/script in apps/web/app/layout.tsx.
|
||||
// See https://www.react-grab.com/
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_REACT_GRAB) {
|
||||
const grab = document.createElement("script");
|
||||
grab.src = "//unpkg.com/react-grab/dist/index.global.js";
|
||||
grab.crossOrigin = "anonymous";
|
||||
document.head.appendChild(grab);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
@@ -13,8 +14,9 @@ export function IssueDetailPage() {
|
||||
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
|
||||
|
||||
if (!id) return null;
|
||||
// Render errors bubble to the root route errorElement (DesktopRouteErrorPage),
|
||||
// which contains the crash inside the tab content pane. No page-level boundary
|
||||
// here — a whole-page wrapper duplicates the route-level error UI.
|
||||
return <IssueDetail issueId={id} />;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<IssueDetail issueId={id} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,14 +11,7 @@ import type { AgentRuntime } from "@multica/core/types";
|
||||
* to the desktop preload typings (which live in apps/desktop/src/preload).
|
||||
*/
|
||||
interface DaemonStatusLike {
|
||||
state:
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "stopping"
|
||||
| "installing_cli"
|
||||
| "cli_not_found"
|
||||
| "auth_expired";
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
daemonId?: string;
|
||||
}
|
||||
|
||||
@@ -32,11 +25,7 @@ interface DaemonStatusLike {
|
||||
* within 75s.
|
||||
*/
|
||||
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
|
||||
if (
|
||||
status.state === "stopped" ||
|
||||
status.state === "stopping" ||
|
||||
status.state === "auth_expired"
|
||||
) {
|
||||
if (status.state === "stopped" || status.state === "stopping") {
|
||||
return { ...rt, status: "offline" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockGetState, logout } = vi.hoisted(() => ({
|
||||
mockGetState: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}));
|
||||
|
||||
const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() }));
|
||||
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: { getState: mockGetState },
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: toastError },
|
||||
}));
|
||||
|
||||
import { reauthenticateDaemon } from "./daemon-reauth";
|
||||
|
||||
const daemonAPI = {
|
||||
reauthenticate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
(window as unknown as { daemonAPI: typeof daemonAPI }).daemonAPI = daemonAPI;
|
||||
mockGetState.mockReturnValue({ user: { id: "user-1" }, logout });
|
||||
});
|
||||
|
||||
describe("reauthenticateDaemon", () => {
|
||||
it("re-mints + restarts the daemon when signed in, without logging out", async () => {
|
||||
localStorage.setItem("multica_token", "jwt-abc");
|
||||
daemonAPI.reauthenticate.mockResolvedValue({ ok: true });
|
||||
|
||||
await reauthenticateDaemon();
|
||||
|
||||
expect(daemonAPI.reauthenticate).toHaveBeenCalledWith("jwt-abc", "user-1");
|
||||
expect(logout).not.toHaveBeenCalled();
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs out only when the session token itself is rejected (401)", async () => {
|
||||
localStorage.setItem("multica_token", "jwt-abc");
|
||||
daemonAPI.reauthenticate.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "session_invalid",
|
||||
});
|
||||
|
||||
await reauthenticateDaemon();
|
||||
|
||||
expect(logout).toHaveBeenCalledOnce();
|
||||
expect(toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The reviewer's must-fix: a non-401 (transient) failure must NOT log the
|
||||
// user out — they stay signed in and can retry.
|
||||
it("does NOT log out on a transient failure; shows a retryable toast", async () => {
|
||||
localStorage.setItem("multica_token", "jwt-abc");
|
||||
daemonAPI.reauthenticate.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "transient",
|
||||
message: "mint PAT failed: 503 Service Unavailable",
|
||||
});
|
||||
|
||||
await reauthenticateDaemon();
|
||||
|
||||
expect(logout).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does NOT log out when the IPC call itself throws unexpectedly", async () => {
|
||||
localStorage.setItem("multica_token", "jwt-abc");
|
||||
daemonAPI.reauthenticate.mockRejectedValue(new Error("ipc boom"));
|
||||
|
||||
await reauthenticateDaemon();
|
||||
|
||||
expect(logout).not.toHaveBeenCalled();
|
||||
expect(toastError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("routes to login when there is no session token", async () => {
|
||||
await reauthenticateDaemon();
|
||||
|
||||
expect(logout).toHaveBeenCalledOnce();
|
||||
expect(daemonAPI.reauthenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes to login when there is no signed-in user", async () => {
|
||||
localStorage.setItem("multica_token", "jwt-abc");
|
||||
mockGetState.mockReturnValue({ user: null, logout });
|
||||
|
||||
await reauthenticateDaemon();
|
||||
|
||||
expect(logout).toHaveBeenCalledOnce();
|
||||
expect(daemonAPI.reauthenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Re-establish the local daemon's credentials after it failed to authenticate
|
||||
* (daemon state "auth_expired", surfaced by daemon-manager's token probe — see
|
||||
* #3512).
|
||||
*
|
||||
* The desktop owns the daemon's PAT: it mints one from the user's session token
|
||||
* and caches it per profile. A stale/revoked cached PAT is the common cause (and
|
||||
* merely restarting the app reuses the same bad PAT), so the main process drops
|
||||
* the cached token, mints a fresh one, and restarts the daemon.
|
||||
*
|
||||
* Failure handling is deliberately conservative — we only force a full re-login
|
||||
* when the session token itself is rejected (a real 401). A transient failure
|
||||
* (mint 5xx, network blip, config write error, restart hiccup) keeps the user
|
||||
* signed in and shows a retryable toast, so a momentary glitch never logs them
|
||||
* out. The 401-vs-transient classification happens in the main process where the
|
||||
* real HTTP status is available; here we just act on the verdict.
|
||||
*/
|
||||
export async function reauthenticateDaemon(): Promise<void> {
|
||||
const user = useAuthStore.getState().user;
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!user || !token) {
|
||||
// No usable session at all — the standard recovery is the login page.
|
||||
useAuthStore.getState().logout();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.daemonAPI.reauthenticate(token, user.id);
|
||||
if (result.ok) return; // daemon restarting; status flips via onStatusChange
|
||||
if (result.reason === "session_invalid") {
|
||||
// The session token itself is rejected (401) — full re-login.
|
||||
useAuthStore.getState().logout();
|
||||
return;
|
||||
}
|
||||
// Transient failure — keep the user signed in and let them retry.
|
||||
toast.error("Couldn't reconnect the daemon", {
|
||||
description: result.message || "Please try again in a moment.",
|
||||
});
|
||||
} catch (err) {
|
||||
// An unexpected IPC error is not an auth failure — never log out on it.
|
||||
toast.error("Couldn't reconnect the daemon", {
|
||||
description: err instanceof Error ? err.message : "Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,11 @@ import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/vie
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { useT } from "@multica/views/i18n";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
import { DesktopRouteErrorPage } from "./components/route-error-page";
|
||||
|
||||
/**
|
||||
* Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels
|
||||
@@ -109,7 +109,6 @@ function PageShell() {
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <PageShell />,
|
||||
errorElement: <DesktopRouteErrorPage />,
|
||||
children: [
|
||||
{ index: true, element: null },
|
||||
{
|
||||
@@ -119,7 +118,11 @@ export const appRoutes: RouteObject[] = [
|
||||
{ index: true, element: <Navigate to="issues" replace /> },
|
||||
{
|
||||
path: "issues",
|
||||
element: <IssuesPage />,
|
||||
element: (
|
||||
<ErrorBoundary>
|
||||
<IssuesPage />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
handle: { title: "Issues" },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -259,47 +259,6 @@ describe("useTabStore actions", () => {
|
||||
expect(s.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
|
||||
it("validateWorkspaceSlugs seeds the first valid workspace when no group exists", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.validateWorkspaceSlugs(new Set(["acme", "butter"]));
|
||||
const s = useTabStore.getState();
|
||||
expect(s.activeWorkspaceSlug).toBe("acme");
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
|
||||
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
|
||||
});
|
||||
|
||||
it("validateWorkspaceSlugs reactivates an existing valid group before seeding", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const existingTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
|
||||
useTabStore.setState({ activeWorkspaceSlug: null });
|
||||
store.validateWorkspaceSlugs(new Set(["acme"]));
|
||||
|
||||
const s = useTabStore.getState();
|
||||
expect(s.activeWorkspaceSlug).toBe("acme");
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
|
||||
expect(s.byWorkspace.acme.tabs[0].id).toBe(existingTabId);
|
||||
});
|
||||
|
||||
it("validateWorkspaceSlugs seeds a fresh tab for a valid slug after dropping all stale groups", () => {
|
||||
const store = useTabStore.getState();
|
||||
// The only persisted group points at a workspace the user has lost access
|
||||
// to — the stale-tab heal path WorkspaceRouteLayout drives.
|
||||
store.switchWorkspace("stale");
|
||||
const staleRouter = useTabStore.getState().byWorkspace.stale.tabs[0].router;
|
||||
|
||||
store.validateWorkspaceSlugs(new Set(["acme"]));
|
||||
|
||||
const s = useTabStore.getState();
|
||||
expect(Object.keys(s.byWorkspace)).toEqual(["acme"]);
|
||||
expect(s.activeWorkspaceSlug).toBe("acme");
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
|
||||
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
|
||||
// The dropped stale group's router must be disposed, not leaked.
|
||||
expect(staleRouter.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reset wipes the whole store", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
|
||||
@@ -86,16 +86,6 @@ interface TabStore {
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Patch history tracking of a tab. Finds across groups. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/** Recreate the active tab's router at the same path after a route-level crash. */
|
||||
reloadActiveTab: () => void;
|
||||
/**
|
||||
* Close the active tab. The always-safe escape from a route-level crash:
|
||||
* unlike reloadActiveTab (recreates the same crashing path) or navigating
|
||||
* to a "safe" route (which may itself be the route that crashed), closing
|
||||
* destroys the crashing router entirely and falls back to a sibling tab
|
||||
* (or a reseeded default if it was the last tab).
|
||||
*/
|
||||
closeActiveTab: () => void;
|
||||
/**
|
||||
* Reorder within the active workspace's group only. Clamped so a tab can
|
||||
* never cross the pinned / unpinned boundary — a drag that would move a
|
||||
@@ -485,38 +475,6 @@ export const useTabStore = create<TabStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
reloadActiveTab() {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
const index = group.tabs.findIndex((t) => t.id === group.activeTabId);
|
||||
if (index < 0) return;
|
||||
const current = group.tabs[index];
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = {
|
||||
...current,
|
||||
router: createTabRouter(current.path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
window.setTimeout(() => current.router.dispose(), 0);
|
||||
},
|
||||
|
||||
closeActiveTab() {
|
||||
const { activeWorkspaceSlug, byWorkspace, closeTab } = get();
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
closeTab(group.activeTabId);
|
||||
},
|
||||
|
||||
moveTab(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex) return;
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
@@ -599,24 +557,6 @@ export const useTabStore = create<TabStore>()(
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!nextActive) {
|
||||
nextActive = Object.keys(nextByWorkspace)[0] ?? null;
|
||||
if (nextActive) changed = true;
|
||||
}
|
||||
|
||||
if (!nextActive) {
|
||||
const fallbackSlug = validSlugs.values().next().value;
|
||||
if (fallbackSlug) {
|
||||
const fresh = defaultTabFor(fallbackSlug);
|
||||
nextByWorkspace[fallbackSlug] = {
|
||||
tabs: [fresh],
|
||||
activeTabId: fresh.id,
|
||||
};
|
||||
nextActive = fallbackSlug;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
set({ byWorkspace: nextByWorkspace, activeWorkspaceSlug: nextActive });
|
||||
},
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { daemonStatusAlive } from "./daemon-types";
|
||||
|
||||
describe("daemonStatusAlive", () => {
|
||||
it("treats a ready daemon as alive", () => {
|
||||
expect(daemonStatusAlive("running")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a still-booting daemon as alive", () => {
|
||||
// /health binds before preflight and reports "starting" until ready; the
|
||||
// Desktop must not spawn a second daemon over it (the CLI rejects that as
|
||||
// "already running").
|
||||
expect(daemonStatusAlive("starting")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats stopped / unknown / missing as not alive", () => {
|
||||
expect(daemonStatusAlive("stopped")).toBe(false);
|
||||
expect(daemonStatusAlive("bogus")).toBe(false);
|
||||
expect(daemonStatusAlive("")).toBe(false);
|
||||
expect(daemonStatusAlive(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,7 @@ export type DaemonState =
|
||||
| "starting"
|
||||
| "stopping"
|
||||
| "installing_cli"
|
||||
| "cli_not_found"
|
||||
// The daemon can't start because the server rejected its credentials (the
|
||||
// cached PAT expired / was revoked, or the session token is dead). Without
|
||||
// this, an auth failure silently sticks at "starting" forever — see #3512.
|
||||
| "auth_expired";
|
||||
| "cli_not_found";
|
||||
|
||||
export interface DaemonStatus {
|
||||
state: DaemonState;
|
||||
@@ -22,16 +18,6 @@ export interface DaemonStatus {
|
||||
profile?: string;
|
||||
/** Backend URL the daemon connects to. */
|
||||
serverUrl?: string;
|
||||
/**
|
||||
* True when a daemon is running but in an environment the app can't control
|
||||
* — its reported OS differs from the desktop host's (e.g. a Linux daemon
|
||||
* inside WSL2 behind a Windows desktop, reachable only via localhost
|
||||
* forwarding). The app's start/stop CLI acts on the host process namespace,
|
||||
* so auto-start/auto-stop can't reach it; the UI disables those toggles
|
||||
* instead of silently no-op'ing. Only ever set on a running daemon, so it
|
||||
* never disables the toggles for a normally-managed native daemon. See #3916.
|
||||
*/
|
||||
externallyManaged?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonPrefs {
|
||||
@@ -46,7 +32,6 @@ export const DAEMON_STATE_COLORS: Record<DaemonState, string> = {
|
||||
stopping: "bg-amber-500 animate-pulse",
|
||||
installing_cli: "bg-sky-500 animate-pulse",
|
||||
cli_not_found: "bg-red-500",
|
||||
auth_expired: "bg-red-500",
|
||||
};
|
||||
|
||||
export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
|
||||
@@ -56,7 +41,6 @@ export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
|
||||
stopping: "Stopping…",
|
||||
installing_cli: "Setting up…",
|
||||
cli_not_found: "Setup Failed",
|
||||
auth_expired: "Sign-in required",
|
||||
};
|
||||
|
||||
export function formatUptime(uptime?: string): string {
|
||||
@@ -68,19 +52,6 @@ export function formatUptime(uptime?: string): string {
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a raw daemon `/health` `status` value means a live daemon is on the
|
||||
* port — either fully "running" (ready) or still "starting" (port bound,
|
||||
* preflight in progress). Mirrors the Go `daemonAlive()` in
|
||||
* server/cmd/multica/cmd_daemon.go so the Desktop lifecycle agrees with the
|
||||
* CLI: a "starting" daemon is already there and must not be spawned over (the
|
||||
* CLI rejects that as "already running"). This is liveness, not readiness —
|
||||
* version-restart decisions still gate on the stricter "running".
|
||||
*/
|
||||
export function daemonStatusAlive(status: string | undefined): boolean {
|
||||
return status === "running" || status === "starting";
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing description for the local daemon's current state. Replaces the
|
||||
* raw state label ("Running" / "Stopped") with a sentence that answers
|
||||
@@ -110,7 +81,5 @@ export function daemonStateDescription(state: DaemonState, runtimeCount: number)
|
||||
return "Setting up the runtime for the first time. Only happens once.";
|
||||
case "cli_not_found":
|
||||
return "Setup failed · couldn't download the runtime. Check your network.";
|
||||
case "auth_expired":
|
||||
return "Sign-in expired · sign in again to bring this device back online.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* A freeze/crash breadcrumb persisted by the main process and flushed to
|
||||
* telemetry by the next renderer boot. Shared across main, preload, and
|
||||
* renderer because all three touch it. See main/freeze-breadcrumb.ts for the
|
||||
* read/write logic and the rationale.
|
||||
*/
|
||||
export interface FreezeBreadcrumb {
|
||||
/** "unresponsive" (hang) or "render-process-gone" (crash). */
|
||||
kind: string;
|
||||
/** Diagnostic context captured at failure time (route, window url, …). */
|
||||
context: Record<string, unknown>;
|
||||
/** Epoch ms when the failure was recorded. */
|
||||
ts: number;
|
||||
/** App version at failure time. */
|
||||
version: string;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
export const RENDERER_ROUTE_CONTEXT_CHANNEL = "renderer:route-context";
|
||||
|
||||
export type RendererRouteSurface = "login" | "overlay" | "tab";
|
||||
|
||||
export type RendererRouteContextInput = {
|
||||
surface: RendererRouteSurface;
|
||||
path: string;
|
||||
workspaceSlug?: string;
|
||||
tabId?: string;
|
||||
};
|
||||
|
||||
export type RendererRouteContext = RendererRouteContextInput & {
|
||||
reportedAt: string;
|
||||
};
|
||||
|
||||
const MAX_ROUTE_CONTEXT_STRING_LENGTH = 512;
|
||||
|
||||
export function sanitizeRendererRouteContext(
|
||||
value: unknown,
|
||||
reportedAt = new Date(),
|
||||
): RendererRouteContext | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
if (!isRendererRouteSurface(input.surface)) return null;
|
||||
|
||||
const path = sanitizeString(input.path);
|
||||
if (!path) return null;
|
||||
|
||||
const workspaceSlug = sanitizeString(input.workspaceSlug);
|
||||
const tabId = sanitizeString(input.tabId);
|
||||
|
||||
return {
|
||||
surface: input.surface,
|
||||
path,
|
||||
...(workspaceSlug ? { workspaceSlug } : {}),
|
||||
...(tabId ? { tabId } : {}),
|
||||
reportedAt: reportedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isRendererRouteSurface(value: unknown): value is RendererRouteSurface {
|
||||
return value === "login" || value === "overlay" || value === "tab";
|
||||
}
|
||||
|
||||
function sanitizeString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.slice(0, MAX_ROUTE_CONTEXT_STRING_LENGTH);
|
||||
}
|
||||
@@ -11,13 +11,21 @@ import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { uiTranslations, localeLabels } from "@/lib/translations";
|
||||
import { DocsSettings } from "@/components/docs-settings";
|
||||
|
||||
// Inter (Latin UI face) is exposed under `--font-inter`. The full `--font-sans`
|
||||
// stack — Inter + the per-locale CJK fallback chain, including the Japanese-first
|
||||
// override scoped to `<html lang="ja">` — is composed in static CSS in
|
||||
// ./global.css (CSP-safe, no inline <style>). Mirrors apps/web/app/layout.tsx.
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
variable: "--font-sans",
|
||||
fallback: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
"Noto Sans CJK SC",
|
||||
"Apple SD Gothic Neo",
|
||||
"Malgun Gothic",
|
||||
"Noto Sans CJK KR",
|
||||
"sans-serif",
|
||||
],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
|
||||
@@ -17,24 +17,6 @@ function tokenizeCJK(raw: string): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Japanese mixes Hiragana, Katakana and Kanji; the English regex strips them
|
||||
// all, and the zh tokenizer only keeps Han (Kanji), dropping kana entirely.
|
||||
// Tokenize each kana/Kanji codepoint on its own and keep Latin/digit runs
|
||||
// whole — same character-level recall strategy as tokenizeCJK, extended to
|
||||
// the Hiragana (\u3040-\u309f) and Katakana (\u30a0-\u30ff) blocks, plus the
|
||||
// ideographic iteration mark \u3005 which sits just below the kana blocks and
|
||||
// recurs in common words (e.g. the JP for "various", "daily", "individual").
|
||||
function tokenizeJapanese(raw: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const regex = /[\u3005\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff]|[A-Za-z0-9]+/g;
|
||||
const lower = raw.toLowerCase();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(lower)) !== null) {
|
||||
tokens.push(match[0]);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export const { GET } = createFromSource(source, {
|
||||
localeMap: {
|
||||
ko: {
|
||||
@@ -44,15 +26,6 @@ export const { GET } = createFromSource(source, {
|
||||
},
|
||||
},
|
||||
},
|
||||
ja: {
|
||||
components: {
|
||||
tokenizer: {
|
||||
language: "english",
|
||||
normalizationCache: new Map(),
|
||||
tokenize: tokenizeJapanese,
|
||||
},
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
components: {
|
||||
tokenizer: {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const chineseFonts = ["PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"];
|
||||
const koreanFonts = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR"];
|
||||
const japaneseFonts = ["Hiragino Sans", "Yu Gothic", "Noto Sans CJK JP"];
|
||||
|
||||
function expectChineseFontsBeforeKoreanFonts(source: string) {
|
||||
const chineseIndexes = chineseFonts.map((font) => source.indexOf(font));
|
||||
@@ -20,38 +19,13 @@ function expectChineseFontsBeforeKoreanFonts(source: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Japanese Kanji share the Han Unicode block with Chinese, so the docs
|
||||
// Japanese-first CJK stack must be scoped to html[lang|="ja"] (zh/en keep
|
||||
// Chinese-first) and order Japanese fonts before the Chinese families.
|
||||
function expectJapaneseScopedOverride(source: string) {
|
||||
expect(source).toContain('html[lang|="ja"]');
|
||||
|
||||
const japaneseIndexes = japaneseFonts.map((font) => source.indexOf(font));
|
||||
expect(japaneseIndexes).not.toContain(-1);
|
||||
|
||||
const firstJapanese = Math.min(...japaneseIndexes);
|
||||
const lastChinese = Math.max(
|
||||
...chineseFonts.map((font) => source.lastIndexOf(font)),
|
||||
);
|
||||
expect(firstJapanese).toBeLessThan(lastChinese);
|
||||
}
|
||||
|
||||
describe("CJK font fallback order", () => {
|
||||
it("keeps docs Chinese font fallbacks before Korean font fallbacks", () => {
|
||||
const cssSource = readFileSync(
|
||||
resolve(process.cwd(), "app/global.css"),
|
||||
const layoutSource = readFileSync(
|
||||
resolve(process.cwd(), "app/[lang]/layout.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expectChineseFontsBeforeKoreanFonts(cssSource);
|
||||
});
|
||||
|
||||
it("scopes the Japanese-first CJK stack to html[lang|='ja']", () => {
|
||||
const cssSource = readFileSync(
|
||||
resolve(process.cwd(), "app/global.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expectJapaneseScopedOverride(cssSource);
|
||||
expectChineseFontsBeforeKoreanFonts(layoutSource);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,36 +6,6 @@
|
||||
|
||||
@source "../../../packages/ui/**/*.{ts,tsx}";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Font stack. `--font-inter` is the next/font Inter family (+ synthetic
|
||||
* size-adjusted fallback), set on <html> by inter.variable in app/[lang]/layout.tsx.
|
||||
* `--font-sans` is composed here in static CSS so it can be overridden per
|
||||
* `<html lang>` and stays CSP-safe (no inline <style>). Tailwind's `font-sans`
|
||||
* utility resolves `var(--font-sans)`. Mirrors apps/web/app/globals.css.
|
||||
*
|
||||
* Default (en / zh / ko): Latin → Inter, CJK → Chinese then Korean. Chinese MUST
|
||||
* stay before Korean so zh users don't get Korean Hanja glyph shapes.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--font-sans: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Apple SD Gothic Neo",
|
||||
"Malgun Gothic", "Noto Sans CJK KR", sans-serif;
|
||||
}
|
||||
|
||||
/* Japanese: Kanji share the Han Unicode block with Chinese and CSS fallback
|
||||
order is not affected by `<html lang>`, so promote a Japanese-first CJK chain
|
||||
only for Japanese docs (`<html lang="ja">`). `[lang|="ja"]` is the BCP-47
|
||||
language-range selector — matches exactly `ja` or `ja-<region>`, never
|
||||
unrelated subtags like `jam`. Inter still leads for Latin. */
|
||||
html[lang|="ja"] {
|
||||
--font-sans: var(--font-inter), "Hiragino Sans", "Hiragino Kaku Gothic ProN",
|
||||
"Yu Gothic", "YuGothic", "Meiryo", "Noto Sans CJK JP", "Noto Sans JP",
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Microsoft YaHei", "Noto Sans CJK SC", "Apple SD Gothic Neo", "Malgun Gothic",
|
||||
"Noto Sans CJK KR", sans-serif;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Multica Docs — editorial visual identity (v2)
|
||||
*
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
title: エージェントの作成と構成
|
||||
description: エージェントを作成するために必要な最小限のフィールドと、すべての任意設定 — システム指示、環境変数、公開範囲、同時実行制限、アーカイブ。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
[エージェント](/agents)を作成するのに必要なのは 2 つだけです。**名前**と **[AI コーディングツール](/providers)の選択**です。それ以外はすべて任意です — システム指示、モデル、環境変数、CLI 引数、公開範囲、同時実行制限 — デフォルト値でも問題なく動作します。まず動かしてから後で調整しましょう。すべてのフィールドはいつでも変更できます。
|
||||
|
||||
## エージェントを作成する
|
||||
|
||||
前提条件: 使用中のマシンにサポートされている [AI コーディングツール](/providers)が少なくとも 1 つインストールされており(Claude Code、Codex など)、[デーモン](/daemon-runtimes)が実行中であること。まだそこまで準備できていない場合は、[Cloud クイックスタート](/cloud-quickstart)または[セルフホストクイックスタート](/self-host-quickstart)から始めてください。
|
||||
|
||||
準備が整ったら、ワークスペースの **Agents** ページに移動して **+ New** をクリックするか、CLI を使用します。
|
||||
|
||||
```bash
|
||||
multica agent create
|
||||
```
|
||||
|
||||
このフォームには必須フィールドが 2 つだけあります。**name**(ワークスペース内で一意であること)と **runtime**(= AI コーディングツールの選択)です。それ以外のすべてのフィールドは、以下でセクションごとに扱います。
|
||||
|
||||
## AI コーディングツールを選ぶ
|
||||
|
||||
各ランタイムは特定の AI コーディングツールを基盤としています。Multica はそのうち 12 個をサポートします。最も一般的な選択肢は次のとおりです。
|
||||
|
||||
| ツール | 適している場合 |
|
||||
|---|---|
|
||||
| **Claude Code** | Anthropic の公式ツールで、最も完成度の高い機能セットを提供します。**最初の選択として最適です** |
|
||||
| **Codex** | OpenAI 製で、主流の代替手段です |
|
||||
| **Cursor** | Cursor エディターのエコシステムを使うユーザー |
|
||||
| **Copilot** | GitHub アカウントの権限を活用するチーム |
|
||||
| **Gemini** | Google エコシステムのユーザー |
|
||||
|
||||
残りの 7 個(Antigravity、Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw)と、各ツールの完全な機能比較表(セッション再開、MCP、スキル注入パス、モデル選択)は、[AI コーディングツール比較](/providers)で扱います。
|
||||
|
||||
## システム指示を書く
|
||||
|
||||
**システム指示**(`instructions`)はすべてのタスクの先頭に追加され、エージェントがどんな役割を担い、どんなルールに従うべきかを伝えます。
|
||||
|
||||
```text
|
||||
You're a frontend code-review agent. When an issue comes in, read the diff first. Focus only on:
|
||||
- Styling issues (tailwind class names, box model)
|
||||
- Accessibility (a11y)
|
||||
Don't change code — leave suggestions in a comment.
|
||||
```
|
||||
|
||||
空のままにすると(デフォルト)、エージェントは追加の制約なしに、基盤となる AI コーディングツールのネイティブな動作を使用します。
|
||||
|
||||
## モデルを選ぶ
|
||||
|
||||
ほとんどの AI コーディングツールはモデル選択をサポートしています(例えば Claude Code では Sonnet と Opus のどちらかを選べます)。空のままにするとツール自体のデフォルト値が使われ、明示的に 1 つを選ぶとそのモデルが実行されます。各ツールがサポートするモデルは、[AI コーディングツール比較](/providers)にまとめられています。
|
||||
|
||||
モデルの変更は**新しいタスクにのみ適用されます**。すでにディスパッチされたタスクは、ディスパッチ時点で固定されたモデルで実行を続けます。
|
||||
|
||||
## カスタム環境変数 (custom_env)
|
||||
|
||||
**カスタム環境変数**(`custom_env`)を使うと、タスク実行時に追加の環境変数を注入できます。代表的な用途は API キーの設定やアップストリームエンドポイントの切り替えです。
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY = sk-...
|
||||
ANTHROPIC_BASE_URL = https://my-proxy.example.com
|
||||
```
|
||||
|
||||
システムにとって重要な変数は上書きできません。`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`、そして `MULTICA_*` で始まるすべてのキーは、デーモンが静かに無視します(警告ログは残しますが、エラーは発生しません)。
|
||||
|
||||
<Callout type="warning">
|
||||
**`custom_env` の値は Multica サーバーのデータベースに平文で保存されます。** エージェントの list/get レスポンスには環境変数の値がまったく含まれなくなり、不透明な個数だけが返されます。実際の値を読み取るには、ワークスペースの owner または admin が、専用で監査される `GET /api/agents/{id}/env` エンドポイント(CLI: `multica agent env get <id>`)を呼び出す必要があります。タスクを実行中のエージェントは、ホストの owner 資格情報を使って他のエージェントの環境変数を明らかにすることはできません。このエンドポイントはエージェントアクターのセッションを拒否します。
|
||||
|
||||
**価値の高いシークレットは `custom_env` に入れないでください**(本番データベースのパスワード、root レベルのトークンなど)。エージェントには**権限範囲が限定された専用の資格情報**(読み取り専用 API キー、単一スコープの PAT)を使用し、定期的にローテーションしてください。データベースのバックアップと DB 監査は、依然として意味のある露出面として残ります。
|
||||
</Callout>
|
||||
|
||||
## カスタム CLI 引数 (custom_args)
|
||||
|
||||
**カスタム CLI 引数**(`custom_args`)は、AI コーディングツールのコマンドラインに 1 つずつ順に付け足される文字列配列です。
|
||||
|
||||
```json
|
||||
["--max-turns", "100", "--append-system-prompt", "always respond in Chinese"]
|
||||
```
|
||||
|
||||
最終的なコマンドは次のように生成されます。
|
||||
|
||||
```bash
|
||||
claude --model <model> --max-turns 100 --append-system-prompt "always respond in Chinese" [...]
|
||||
```
|
||||
|
||||
引数はシェルを介さずそのまま渡されるため(注入リスクなし)、特定のフラグが認識されるかどうかは AI コーディングツール自体に依存します。この部分はツールによって大きな差があります。
|
||||
|
||||
<Callout type="tip">
|
||||
`custom_env` と `custom_args` には厳格な上限はありませんが、実際には**それぞれ 10 個以内に抑えてください**。多すぎるとコマンドラインが長くなり、起動が遅くなり、メンテナンスも難しくなります。
|
||||
</Callout>
|
||||
|
||||
## 公開範囲
|
||||
|
||||
- **ワークスペース**(`workspace`) — ワークスペースのすべてのメンバーが割り当てできます
|
||||
- **非公開**(`private`) — ワークスペースの owner、admin、またはエージェントの作成者だけが割り当てできます
|
||||
|
||||
新しいエージェントはデフォルトで `private` です。
|
||||
|
||||
**非公開だからといって隠されるわけではありません** — すべてのメンバーが一覧で非公開エージェントの名前と説明を見ることができ、ただし機微な構成は読み取れません(環境変数の値はエージェントの list/get レスポンスに決して現れず、MCP 構成は owner 以外のユーザーにはマスキングされます)。詳しい意味は[エージェント → 誰がエージェントを割り当てられるか](/agents#who-can-assign-an-agent)を参照してください。
|
||||
|
||||
## 同時実行制限
|
||||
|
||||
**同時実行制限**(`max_concurrent_tasks`)は、このエージェントが一度に並列で実行できるタスク数を制御します。デフォルト値は **6** です。上限に達した新しいタスクは拒否されず、キューで待機します。
|
||||
|
||||
これは 2 段階の制限のうち「エージェント層」にすぎません。デーモン自体がより広い上限(デフォルト値 20)を適用し、2 つのうちより厳しい方が優先されます。詳しくは[デーモンとランタイム → 並列で何個のタスクを実行できるか](/daemon-runtimes#how-many-tasks-can-run-in-parallel)にあります。
|
||||
|
||||
この値を変更しても**すでに実行中のタスクはキャンセルされず**、次に処理されるタスクからのみ適用されます。
|
||||
|
||||
## ドメインの専門性をつなぐ: スキル
|
||||
|
||||
作成したエージェントには**スキル**をアタッチできます — タスク実行時に AI コーディングツールへ自動的に届けられる**ナレッジパック**(`SKILL.md` + 補助ファイル)です。新しいスキルを作成したり、GitHub または ClawHub からインポートしたり、マシン上の既存のスキルディレクトリからスキャンしたりできます。[スキル](/skills)を参照してください。
|
||||
|
||||
## アーカイブと復元
|
||||
|
||||
もう使わないエージェントは**アーカイブ**できます — 日常的な画面からは消えますが、履歴データ(実行したタスク、投稿したコメント)はすべてそのまま保持されます。いつでも**復元**して再び作業に投入できます。
|
||||
|
||||
<Callout type="warning">
|
||||
**アーカイブは、そのエージェントに属する未完了のすべてのタスクを即座にキャンセルします** — 実行中、ディスパッチ済み、キュー待ちのタスクがすべて `cancelled` としてマークされ、続行されません。進行中の重要なタスクがある場合は、アーカイブする前に最後まで完了させてください。
|
||||
</Callout>
|
||||
|
||||
アーカイブ済みのエージェントには新しいタスクを割り当てられません。
|
||||
|
||||
## 次のステップ
|
||||
|
||||
- [スキル](/skills) — エージェントにナレッジパックをアタッチする
|
||||
- [AI コーディングツール比較](/providers) — 12 個のツール全体の機能比較表
|
||||
- [エージェントへのイシューの割り当て](/assigning-issues) — 新しく作ったエージェントを作業に投入する
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: エージェント
|
||||
description: "エージェントは Multica ワークスペースの一級メンバーです — イシューを割り当てられ、コメントを投稿し、@ でメンションされることができます。人間との核心的な違いは、エージェントは自分から作業を始め、通知を受け取らない点です。"
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
エージェントは Multica [ワークスペース](/workspaces)の **一級メンバー** です — 人間と同じように、[イシューを割り当てられ](/assigning-issues)、[コメント](/comments)で発言し、[`@` でメンションされ](/mentioning-agents)、[プロジェクト](/projects)をリードできます。核心的な違いはこれです。すべてのエージェントの背後には、あなたのマシンで動作する [AI コーディングツール](/providers)があります。エージェントにタスクを割り当てると、特に促さなくても **数秒以内に自分から作業を始めます** — 急かす必要も、オフラインになることもなく、24時間いつでも利用できます。
|
||||
|
||||
## エージェントができること
|
||||
|
||||
エージェントは人間と同じ「メンバー」の表面を使っており、UI ではほとんど区別されません。
|
||||
|
||||
- **[イシューを割り当てられる](/assigning-issues)** — 担当者に設定された瞬間、自動的に作業を始めます
|
||||
- **[`@` でメンションされる](/mentioning-agents)** — コメントに `@agent-name` と書くと、目覚めてそのコメントを読みます
|
||||
- **[コメント](/comments)を投稿する** — イシューの下で進捗を報告し、人々に返信します
|
||||
- **[プロジェクト](/projects)をリードする** — 人間と同じように、プロジェクトリードに設定できます
|
||||
- **自分で[イシュー](/issues)を開く** — タスクを実行している間に関連する問題を見つけると、直接新しいイシューを作成できます
|
||||
|
||||
協業ビューから見ると、エージェントはただのワークスペースのメンバーです — 人間と同じメンバー一覧に名前が並び、通常はその前に小さなロボットアイコンが付きます。
|
||||
|
||||
## 人間との違い
|
||||
|
||||
いくつかの重要な違いは、実際にエージェントを使い始めて初めて見えてきます。
|
||||
|
||||
- **自分から始めます** — イシューを割り当てたり `@` でメンションしたりすると、Multica が即座にそのタスクをエージェントのランタイムにディスパッチします。人間のようにメッセージを見て応答するまで待つことはありません。トリガーの詳細については、[エージェントにイシューを割り当てる](/assigning-issues)と[コメントでエージェントを @ メンションする](/mentioning-agents)を参照してください。
|
||||
- **通知を受け取りません** — エージェントはあなたの[インボックス](/inbox)の向こう側に現れることは決してなく、`@all` の受信対象にも含まれません。エージェントは「メッセージを読む受信者」ではなく「タスクを実行するためにトリガーされる作業の単位」です。
|
||||
- **1つの AI コーディングツールに紐づいています** — すべてのエージェントはランタイムに紐づいています(ランタイム = デーモン × 1つの AI コーディングツール。[デーモンとランタイム](/daemon-runtimes)を参照)。ツールがオフラインだとエージェントは作業できず、新しいタスクはランタイムが戻るまで待機します。
|
||||
- **アーカイブできます** — もう使わないエージェントをアーカイブすると日常的なビューから消えます。いつでも好きなときに復元できます。アーカイブすると、現在実行中のタスクはすべてキャンセルされます。
|
||||
|
||||
## 誰がエージェントを割り当てられるか
|
||||
|
||||
エージェントを作成するとき、誰がそのエージェントをイシューに割り当てたりプロジェクトリードに設定したりできるかを制御する **可視性(visibility)** を選択します。
|
||||
|
||||
- **Workspace** — ワークスペースの任意のメンバーが割り当てられます
|
||||
- **Private** — ワークスペースの owner、admin、またはエージェントの作成者だけが割り当てられます
|
||||
|
||||
新しいエージェントはデフォルトで **private** です。ワークスペース全体で利用できるようにするには、作成時に可視性を `workspace` に設定するか、後でエージェントの設定で変更してください。役割と権限の完全なマトリクスについては、[メンバーと役割](/members-roles)を参照してください。
|
||||
|
||||
<Callout type="info">
|
||||
**private は「誰が割り当てられるかを制限する」という意味であって、「他の全員から隠す」という意味ではありません。** ワークスペースのすべてのメンバーは、エージェント一覧で private エージェントの名前と説明を見ることができます — 見えないのは設定の詳細だけです(カスタム環境変数、MCP 設定、その他の機密フィールドはマスクされます)。「1人だけに見える」ようにしたい場合、現時点では実現できません。
|
||||
</Callout>
|
||||
|
||||
## 次のステップ
|
||||
|
||||
- [エージェントの作成と構成](/agents-create) — エージェントを作る方法
|
||||
- [スキル](/skills) — エージェントに知識パックを添付する
|
||||
- [スクワッド](/squads) — 適切なエージェントが適切なイシューを担当するよう、リーダーの下にエージェントをグループ化する
|
||||
- [デーモンとランタイム](/daemon-runtimes) — エージェントが実際に動作するために必要なもの
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
title: エージェントにイシューを割り当てる
|
||||
description: イシューをエージェントに渡すと、作業が終わるまで公式の担当者として引き継ぎます — 完全なコンテキストを持ち、イシューのステータスやフィールドを変更できます。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
[イシュー](/issues)を[エージェント](/agents)に割り当てると、作業が終わるまで**公式の担当者**として働きます — イシューの完全なコンテキスト(説明 + すべての[コメント](/comments))を読み、ステータスを変更し、コメントを投稿し、フィールドを編集できます。これは Multica の 4 つのトリガー経路の中で**最も一般的で、最も重い**方式です。同じフローは[スクワッド](/squads)を担当者として受け付けることもできます — その場合、Multica は代わりにスクワッドの**リーダーエージェント**をトリガーします。
|
||||
|
||||
| 経路 | 使う場面 | イシューの変更 | コンテキスト | 優先度 | 自動リトライ |
|
||||
|---|---|---|---|---|---|
|
||||
| **割り当て** | エージェントに所有権を渡す | 担当者を変更 | イシュー + すべてのコメント | イシューから継承 | ✓ |
|
||||
| [**@メンション**](/mentioning-agents) | ちょっと見てもらうために呼び込む | 変更なし | イシュー + トリガーコメント | イシューから継承 | ✓ |
|
||||
| [**チャット**](/chat) | イシューと無関係な 1 対 1 の会話 | イシューは関与しない | 現在の会話履歴 | 固定で medium | ✓ |
|
||||
| [**オートパイロット**](/autopilots) | スケジュールまたは手動の自動化 | モードによる | モードによる | オートパイロットが設定 | ✗ |
|
||||
|
||||
「自動リトライ」とは、インフラ障害(ランタイムのオフライン、タイムアウト)後のリトライを指します。エージェント側のビジネスエラー(たとえばモデルがエラーを報告する場合)はリトライされません。詳しくは [**タスク**](/tasks)を参照してください。
|
||||
|
||||
## UI から割り当てる
|
||||
|
||||
イシュー詳細ページで、**担当者**ピッカーをクリックしてください。ワークスペースのすべてのメンバー、アーカイブされていないすべてのエージェント、アーカイブされていないすべての[スクワッド](/squads)が一覧表示されます。エージェント(またはスクワッド)を選ぶと、イシューはすぐに割り当てられます。
|
||||
|
||||
いくつかのルールがあります。
|
||||
|
||||
- **ワークスペースエージェント**はどのメンバーでも割り当てられます。**プライベートエージェント**はその owner またはワークスペースの admin のみが割り当てられます。
|
||||
- **オンラインのランタイムを持つ**エージェントにのみ割り当てられます — 誰も実行していないエージェントはピッカーで利用不可と表示されます。
|
||||
- イシューのステータスが **Backlog** のとき、割り当てても**エージェントはトリガーされません** — Backlog は一時保管所であり、イシューを Todo または In Progress に移して初めてエージェントがキューに入ります。
|
||||
|
||||
## CLI から割り当てる
|
||||
|
||||
コマンドラインでの同等の操作です。
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` はメンバーのユーザー名またはエージェント名(あいまい一致)を受け付けます。名前が重複するとき — たとえばエージェント `J` の隣に `Cursor - J` がある場合 — は、代わりに `--to-id <uuid>` を渡してください。このとき `multica workspace member list --output json` の `user_id`(メンバー)または `multica agent list --output json` の `id`(エージェント)を使います。UUID 一致は厳密かつ曖昧さがないため、スクリプトや CLI を駆動するエージェントに適しています。`--to` と `--to-id` は同時に使えません。
|
||||
|
||||
割り当て解除:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --unassign
|
||||
```
|
||||
|
||||
## 割り当て後に起こること
|
||||
|
||||
Backlog ではないイシューがエージェントに割り当てられると、Multica はすぐにバックグラウンドで次のことを行います。
|
||||
|
||||
1. イシューから継承した優先度で `queued` 状態の `task` をキューに入れ、エージェントが存在するランタイムへルーティングします。
|
||||
2. エージェントのデーモンが次のポーリング時に `task` を取得し、`dispatched` に遷移させます。
|
||||
3. エージェントが作業を開始すると `task` が `running` に移ります。完了すると `completed` または `failed` になります。
|
||||
4. 実行中、エージェントはイシューのステータスを変更し、コメントを投稿し、フィールドを編集できます — これらの操作はエージェントの ID で表示されます。
|
||||
|
||||
**エージェントがオフラインの場合**、`task` はキューで待機します — **5 分後に `runtime_offline` の理由でタイムアウトして失敗します**。リトライ可能なソース(割り当て、@メンション、チャット)については、Multica が自動的に再度キューに入れます。完全なリトライルールは [**タスク**](/tasks)を参照してください。
|
||||
|
||||
割り当てると、エージェントはイシューに自動的に購読されます — ただし Multica では**エージェントはインボックス通知を受け取りません**(メンバーのみ受け取ります)。この購読は内部的な記録管理にすぎず、ユーザーに見える副作用はありません。
|
||||
|
||||
## 再割り当てまたは割り当て解除
|
||||
|
||||
担当者をエージェント A からエージェント B に変更すると、
|
||||
|
||||
1. **A が進行中だったものはすべてキャンセルされます** — `queued`、`dispatched`、`running` 状態のすべての `task` が `cancelled` と表示されます。
|
||||
2. **B にはすぐに新しい `task` がキューに入ります**(イシューが Backlog でなく、B にオンラインのランタイムがある場合)。
|
||||
|
||||
<Callout type="warning">
|
||||
**再割り当てはこのイシューのすべてのアクティブな `task` をキャンセルします — 以前の担当者のものだけではありません。** 別のエージェントが @メンションによってこのイシューで作業中の場合、その `task` も一緒にキャンセルされます。現在のところ、単一のエージェントの `task` だけを個別にキャンセルする UI 操作はありません。
|
||||
</Callout>
|
||||
|
||||
割り当て解除(`--unassign` またはピッカーで「none」を選択)は、すべてのアクティブな `task` 項目を `cancelled` と表示し、**新しい項目をキューに入れません**。既存の購読は自動的にクリアされません — 以前の担当者は購読リストに残ります(ただし依然としてインボックス通知は受け取りません)。
|
||||
|
||||
## イシューごとエージェントごとにアクティブな `task` が 1 つだけの理由
|
||||
|
||||
**単一のエージェントは、同じイシューで任意の時点に最大 1 つの `queued` または `dispatched` の `task` しか持てません。** データベースレベルの一意インデックスとクレームロジックがこれを強制します — 重複したキュー登録と、同時実行が互いを上書きすることを防ぎます。
|
||||
|
||||
しかし**異なるエージェントは同じイシューで並列に作業できます** — たとえばエージェント A が担当者で、エージェント B が @メンションされた場合、2 つの `task` 項目がそれぞれ自分のランタイムで実行されながら共存できます。完全な直列・並列ルールは [**タスク**](/tasks)を参照してください。
|
||||
|
||||
## 次へ
|
||||
|
||||
- [**コメントでエージェントを @メンションする**](/mentioning-agents) — 担当者とステータスを変えない、より軽いトリガー
|
||||
- [**スクワッド**](/squads) — エージェントのグループに割り当て、リーダーに誰が引き受けるかを決めさせる
|
||||
- [**チャット**](/chat) — イシューと無関係な 1 対 1 の会話
|
||||
- [**オートパイロット**](/autopilots) — エージェントがスケジュールに沿って自動的に作業を開始するようにする
|
||||
@@ -1,186 +0,0 @@
|
||||
---
|
||||
title: ログインとサインアップの構成
|
||||
description: メール + 認証コードログイン、Google OAuth、サインアップ許可リスト、ローカルテストコードを構成します。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
Multica は 2 つのログイン方式をサポートしています。**メール + 認証コード**(デフォルト)と **Google OAuth**(オプション)です。ログインに成功すると、サーバーは 30 日間有効な JWT クッキーを発行します。このページでは、各方式の構成方法、誰がサインアップできるかを制限する方法、そしてセルフホストのデプロイで最も陥りやすい落とし穴を 1 つ取り上げます。
|
||||
|
||||
以下で参照する環境変数の一覧は[環境変数](/environment-variables)を参照してください。トークンの使い方とライフサイクルの詳細は[認証とトークン](/auth-tokens)を参照してください。
|
||||
|
||||
## メール + 認証コードログインの仕組み
|
||||
|
||||
ユーザーがログインページでメールを入力します → サーバーが 6 桁のコードを送信します → ユーザーがコードを入力します → サーバーがコードを検証します → JWT クッキーが発行されます。標準的なフローです。2 つの送信バックエンドがサポートされているので、デプロイ環境に合うほうを選んでください。
|
||||
|
||||
### オプション A: Resend(クラウド / 公開インターネットのデプロイに推奨)
|
||||
|
||||
1. [Resend](https://resend.com/) アカウントを作成し、ドメインを認証します
|
||||
2. API キーを作成します
|
||||
3. 環境変数を設定します:
|
||||
|
||||
```bash
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # must be a domain verified in Resend
|
||||
```
|
||||
|
||||
4. サーバーを再起動します
|
||||
|
||||
### オプション B: SMTP relay(セルフホスト / オンプレミスのデプロイ用)
|
||||
|
||||
デプロイ環境から `api.resend.com` に到達できない場合や、すでに内部メール relay(Microsoft Exchange、Postfix、オンプレミスの SendGrid など)がある場合に使用してください。両方が設定されている場合は `SMTP_HOST` が `RESEND_API_KEY` より優先されます。`SMTP_HOST` が空でなければ、`RESEND_API_KEY` も併せて構成されていても、サーバーは常に SMTP を経由するため、認証メールと招待メールが内部ネットワークの外に出ることは決してありません。
|
||||
|
||||
SMTP 経路は、ほとんどのオンプレミスメールサーバー(特に Microsoft Exchange の receive connector)が公開する 3 つの relay モードをサポートします。
|
||||
|
||||
| モード | ポート | 認証 | TLS |
|
||||
|---|---|---|---|
|
||||
| 匿名内部 relay | `25` | なし — IP / サブネットで送信を信頼 | 伝送経路上はなし(内部セグメント専用) |
|
||||
| 認証付き送信(submission) | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS、自動アップグレード |
|
||||
| 暗黙的 TLS(SMTPS) | `465` | 任意(`SMTP_USERNAME` + `SMTP_PASSWORD`) | 接続時に TLS ハンドシェイク — ポート `465` で自動的に有効化、非標準ポートでは `SMTP_TLS=implicit` で強制 |
|
||||
|
||||
**ポート 25 の匿名 Exchange relay** — 認証情報なしで信頼されたサブネットからのメールを受け入れる、典型的な「internal SMTP relay」/ Exchange 匿名 receive connector:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=exchange.internal.example.com
|
||||
SMTP_PORT=25
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_INSECURE=false
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
**ポート 587 の認証付き送信** — サービスアカウントを必要とする relay 用。サーバーが STARTTLS のサポートを通知すると自動的にアップグレードされます:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=multica
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**ポート 465 の暗黙的 TLS(SMTPS)** — SMTPS のみを提供し STARTTLS を通知しないプロバイダー(例: Aliyun / Tencent のエンタープライズメール)向け。ポート `465` は暗黙的 TLS を自動的に有効化します。`SMTP_TLS=implicit`(別名: `smtps`、`ssl`)は非標準の SMTPS ポートでこれを強制します:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.qiye.aliyun.com
|
||||
SMTP_PORT=465 # implicit TLS auto-enabled on 465
|
||||
SMTP_USERNAME=multica@yourdomain.com
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**厳格な公開 relay(例: Google Workspace `smtp-relay.gmail.com`)** はさらに有効な EHLO 名を必要とします。これらの relay は公開 IP からのデフォルトの `localhost` 挨拶を拒否し、relay が接続を切断します — これは挨拶の時点ではなく、後続のコマンドで不明瞭な `EOF`(`smtp auth: EOF`)として表面化します。`SMTP_EHLO_NAME` を relay が期待する FQDN に設定してください。デフォルトはマシンのホスト名で、コンテナ内では通常は有効な FQDN ではありません。
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp-relay.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
起動時に、サーバーは選択したプロバイダーを、ネゴシエートされた TLS モードも含めて出力します。例えば `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` や `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…`(または `Resend API` / `DEV mode`)のように表示されます。パスワードがログに記録されることは決してありません。再起動後に SMTP の行が見えない場合は `SMTP_HOST` がプロセスに届いていないので、コンテナ環境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)を確認してください。
|
||||
|
||||
**どちらも設定しない場合**: サーバーはエラーを出しませんが、**送信されるはずだったすべてのメールがサーバーの stdout にのみ書き出されます**。ローカル開発には便利ですが(ログからコードをコピーできます)、プロダクションではブラックホールになります。
|
||||
|
||||
## 固定ローカルテストコード
|
||||
|
||||
<Callout type="warning">
|
||||
**公開アクセス可能なインスタンスでは固定の認証コードを有効にしないでください。**
|
||||
|
||||
非プロダクションのインスタンスがデフォルトで `888888` を受け入れていた従来の動作は削除されました。明示的に構成しない限り、`888888` の入力は他の誤ったコードと同じように扱われます。
|
||||
|
||||
メールバックエンドをまったく構成していない(Resend も SMTP もない)ローカル開発では、サーバーログに出力される生成されたコードを使用してください。決定論的なローカル / プライベートの自動化が必要な場合は、`MULTICA_DEV_VERIFICATION_CODE` を `888888` のような 6 桁の値に設定し、`APP_ENV` を非プロダクションに保ってください:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
MULTICA_DEV_VERIFICATION_CODE=888888
|
||||
```
|
||||
|
||||
このショートカットは `APP_ENV=production` のときは無視されます。
|
||||
</Callout>
|
||||
|
||||
プロダクションのデプロイでは `MULTICA_DEV_VERIFICATION_CODE` を空のままにし、`APP_ENV=production` に設定してください。`make selfhost` / `docker-compose.selfhost.yml` でデプロイする場合、`APP_ENV` はデフォルトで `production` です。
|
||||
|
||||
## Google OAuth の構成
|
||||
|
||||
オプションです。構成しないとメール + 認証コードのみが利用可能で、構成するとログインページに「Sign in with Google」ボタンが追加されます。
|
||||
|
||||
1. [Google Cloud Console](https://console.cloud.google.com/) で OAuth 2.0 クライアントを作成します
|
||||
2. **Authorized redirect URIs** を Multica フロントエンドのアドレスに `/auth/callback` を加えた値に設定します。例:
|
||||
|
||||
```text
|
||||
https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
3. クライアント ID とクライアント secret を取得したら、3 つの環境変数を設定します:
|
||||
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
|
||||
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
4. サーバーを再起動します。
|
||||
|
||||
**ランタイムで反映されます**: フロントエンドは `/api/config` を通じてランタイムにこれらの設定を読み込みます — 変更後にサーバーを再起動すると、フロントエンドはリビルドや再デプロイなしで新しい値を取得します。
|
||||
|
||||
<Callout type="warning">
|
||||
**リダイレクト URI は Google Console と `GOOGLE_REDIRECT_URI` の両方で完全に一致している必要があります** — プロトコル(`http` と `https`)、末尾のスラッシュ、ポートを含みます。少しでも一致しないと Google は OAuth フロー全体を拒否し、ユーザーに表示されるエラーは `redirect_uri_mismatch` です。
|
||||
</Callout>
|
||||
|
||||
## 誰がサインアップできるかを制限する
|
||||
|
||||
3 つの環境変数が優先順位に従って組み合わされます。
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
Start[New user first sign-in] --> A{Email in<br/>ALLOWED_EMAILS?}
|
||||
A -- Yes --> Allow[Allow signup]
|
||||
A -- No --> B{Domain in<br/>ALLOWED_EMAIL_DOMAINS?}
|
||||
B -- Yes --> Allow
|
||||
B -- No --> C{Any allowlist<br/>non-empty?}
|
||||
C -- Yes --> Block[Reject]
|
||||
C -- No --> D{ALLOW_SIGNUP<br/>= true?}
|
||||
D -- Yes --> Allow
|
||||
D -- No --> Block
|
||||
`} />
|
||||
|
||||
**既存のユーザーはいつでも再ログインできます** — サインアップ許可リストは**初回サインアップ**にのみ適用され、戻ってくるユーザーは妨げられません。
|
||||
|
||||
- **`ALLOWED_EMAILS`**(最高優先度) — 明示的なメール許可リスト、カンマ区切り。**空でない場合、リストにあるメールのみがサインアップできます。**
|
||||
- **`ALLOWED_EMAIL_DOMAINS`** — ドメイン許可リスト、カンマ区切り(例: `company.io,partner.com`)。
|
||||
- **`ALLOW_SIGNUP`** — マスタースイッチ、デフォルト `true`。`false` に設定するとサインアップが完全に無効になります。
|
||||
|
||||
<Callout type="warning">
|
||||
**3 つの層は OR ではなく AND のセマンティクスです。** よくある誤った直感は、`ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` が「company.io に加えて他の全員を許可する」という意味だと考えることです。そうでは**ありません**。いずれかの層に空でない値があると、**それに一致しないメールはただちに拒否され**、`ALLOW_SIGNUP=true` はそれを無効にできません。
|
||||
|
||||
実際に「全員を許可」するには、3 つの変数をすべて空のままにしてください(または `ALLOW_SIGNUP=true` を維持してください)。
|
||||
</Callout>
|
||||
|
||||
**典型的な構成**:
|
||||
|
||||
| 目的 | 構成 |
|
||||
|---|---|
|
||||
| 内部専用、`company.io` の従業員のみ | `ALLOWED_EMAIL_DOMAINS=company.io` |
|
||||
| 内部 + 少数の外部コラボレーター | `ALLOWED_EMAIL_DOMAINS=company.io` + コラボレーターのアドレスを `ALLOWED_EMAILS` に追加 |
|
||||
| セルフサービスのサインアップを完全に無効化、招待のみ | `ALLOW_SIGNUP=false` |
|
||||
| 開放型サインアップ(プロダクションには非推奨) | 3 つすべて空 |
|
||||
|
||||
## サインアップを無効にしても人を招待できますか?
|
||||
|
||||
**すでに Multica アカウントを持っている人のみ可能です。** 招待の受諾はサインアップ許可リストをチェックしません — 招待された人がすでにサインアップ済み(例えば別のワークスペースで)であれば、招待リンクをクリックしてログインすれば受諾できます。
|
||||
|
||||
**しかし一度もサインアップしていない人は招待で救うことはできません。** 受諾する前にまずログインする必要があり、ログインの最初のステップ(認証コードの要求)はサインアップ許可リストのチェックを通過します。`ALLOW_SIGNUP=false` であるか、そのメールが `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` にない場合、**サインアップを完了できず**、したがって招待を受諾することもできません。
|
||||
|
||||
まだサインアップしていない外部コラボレーターを招待するには: そのメールを `ALLOWED_EMAILS` に一時的に追加し、その人がサインアップして招待を受諾するのを待ってから、エントリを削除してください。
|
||||
|
||||
招待の作成と使用方法については[メンバーとロール](/members-roles)を参照してください。
|
||||
|
||||
## 次に
|
||||
|
||||
- [環境変数](/environment-variables) — このページで使用するすべての変数の完全な定義
|
||||
- [認証とトークン](/auth-tokens) — JWT / PAT / デーモントークンの分類と使い方
|
||||
- [トラブルシューティング](/troubleshooting) — 認証コードが届かない、OAuth `redirect_uri_mismatch`、サインアップ拒否
|
||||
@@ -37,7 +37,7 @@ SMTP 경로는 대부분의 온프레미스 메일 서버(특히 Microsoft Excha
|
||||
|---|---|---|---|
|
||||
| 익명 내부 relay | `25` | 없음 — IP / 서브넷으로 제출을 신뢰 | 전송 경로상 없음(내부 세그먼트 전용) |
|
||||
| 인증된 제출(submission) | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, 자동 업그레이드 |
|
||||
| 암묵적 TLS (SMTPS) | `465` | 선택 사항(`SMTP_USERNAME` + `SMTP_PASSWORD`) | 연결 시 TLS 핸드셰이크 — 포트 `465`에서 자동 활성화, 비표준 포트에서는 `SMTP_TLS=implicit`로 강제 |
|
||||
| 암묵적 TLS (SMTPS) | `465` | — | **아직 지원하지 않음** — 포트 25 또는 587을 사용하세요 |
|
||||
|
||||
**포트 25의 익명 Exchange relay** — 자격 증명 없이 신뢰된 서브넷에서 오는 메일을 받아들이는 일반적인 "internal SMTP relay" / Exchange 익명 receive connector:
|
||||
|
||||
@@ -61,27 +61,7 @@ SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**포트 465의 암묵적 TLS(SMTPS)** — SMTPS만 제공하고 STARTTLS를 알리지 않는 제공자(예: Aliyun / Tencent 엔터프라이즈 메일)용. 포트 `465`는 암묵적 TLS를 자동으로 활성화하며, `SMTP_TLS=implicit`(별칭: `smtps`, `ssl`)는 비표준 SMTPS 포트에서 이를 강제합니다:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.qiye.aliyun.com
|
||||
SMTP_PORT=465 # implicit TLS auto-enabled on 465
|
||||
SMTP_USERNAME=multica@yourdomain.com
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**엄격한 공개 relay(예: Google Workspace `smtp-relay.gmail.com`)** 는 추가로 유효한 EHLO 이름을 요구합니다. 이들은 공개 IP에서 보내는 기본 `localhost` greeting을 거부하며, relay가 연결을 끊습니다 — 이는 greeting 단계가 아니라 이후 명령에서 불투명한 `EOF`(`smtp auth: EOF`)로 나타납니다. relay가 기대하는 FQDN으로 `SMTP_EHLO_NAME`을 설정하세요. 기본값은 머신 호스트명이며, 컨테이너 안에서는 보통 유효한 FQDN이 아닙니다:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp-relay.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
시작 시 서버는 협상된 TLS 모드를 포함하여 선택한 제공자를 출력합니다. 예를 들어 `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` 또는 `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…`(또는 `Resend API` / `DEV mode`)와 같이 표시됩니다. 비밀번호는 절대 로그에 기록되지 않습니다. 재시작 후 SMTP 줄이 보이지 않는다면 `SMTP_HOST`가 프로세스에 도달하지 못한 것이므로, 컨테이너 환경(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)을 확인하세요.
|
||||
시작 시 서버는 선택한 제공자를 출력합니다. 예를 들어 `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com`(또는 `Resend API` / `DEV mode`)와 같이 표시됩니다. 비밀번호는 절대 로그에 기록되지 않습니다. 재시작 후 SMTP 줄이 보이지 않는다면 `SMTP_HOST`가 프로세스에 도달하지 못한 것이므로, 컨테이너 환경(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)을 확인하세요.
|
||||
|
||||
**둘 다 설정하지 않으면**: 서버는 오류를 내지 않지만, **전송되어야 했던 모든 이메일이 서버의 stdout에만 기록됩니다**. 로컬 개발에는 편리하지만(로그에서 코드를 복사하면 됩니다), 프로덕션에서는 블랙홀이 됩니다.
|
||||
|
||||
|
||||
@@ -72,15 +72,6 @@ SMTP_TLS=implicit # optional on 465; required on a non-standard SMT
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**Strict public relays (e.g. Google Workspace `smtp-relay.gmail.com`)** additionally require a valid EHLO name. They reject the default `localhost` greeting from a public IP, and the relay drops the connection — which surfaces as an opaque `EOF` on a later command (`smtp auth: EOF`) rather than at the greeting. Set `SMTP_EHLO_NAME` to the FQDN the relay expects; it defaults to the machine hostname, which inside a container is usually not a valid FQDN:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp-relay.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
At startup the server prints which provider it picked, including the negotiated TLS mode — for example `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` or `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…` (or `Resend API` / `DEV mode`). The password is never logged. If you don't see the SMTP line after restart, `SMTP_HOST` didn't reach the process — check the container env (`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`).
|
||||
|
||||
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
|
||||
@@ -72,15 +72,6 @@ SMTP_TLS=implicit # 465 上可省略;在非标准 SMTPS 端口上
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**严格公网 relay(例如 Google Workspace `smtp-relay.gmail.com`)**还要求一个合法的 EHLO 名称。它们会拒绝来自公网 IP 的默认 `localhost` 问候,relay 随即断开连接——这不会在问候阶段报错,而是在后续某条命令上表现为一个不知所云的 `EOF`(`smtp auth: EOF`)。把 `SMTP_EHLO_NAME` 设成 relay 期望的 FQDN;它默认取机器主机名,而在容器内这通常不是合法的 FQDN:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp-relay.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_EHLO_NAME=mail.yourdomain.com # relay 接受的 FQDN;默认取(非 FQDN 的)容器主机名
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
启动时 server 会打印当前选择的 provider 和协商出的 TLS 模式,比如 `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` 或 `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…`(或 `Resend API` / `DEV mode`),密码不会出现在日志里。重启后没看到 SMTP 这行,说明 `SMTP_HOST` 没进到进程,确认下容器环境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)。
|
||||
|
||||
**两种都不配**:server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
title: 認証とトークン
|
||||
description: Multica には 3 種類のトークンがあります — ブラウザ、CLI、デーモンにそれぞれ 1 つずつ。どの場面でどれを使うかを解説します。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica には 3 種類のトークンがあり、それぞれが 1 つのコンテキストに対応します。ブラウザの Web UI、コマンドラインとスクリプト、そしてデーモンです。3 つとも同じあなたを表しますが、スコープと有効期間が異なります。
|
||||
|
||||
## 3 つのトークン
|
||||
|
||||
| トークン | 形式 | 使われる場所 | 有効期間 |
|
||||
|---|---|---|---|
|
||||
| **JWT クッキー** | `multica_auth` クッキー (HttpOnly) | Web ブラウザ | 30 日 |
|
||||
| **個人アクセストークン (PAT)** | `mul_` プレフィックス | CLI、スクリプト、直接の API 呼び出し | デフォルトでは期限なし。API で作成する際に `expires_in_days` を渡せます |
|
||||
| **デーモントークン** | `mdt_` プレフィックス | デーモンとサーバー間の通信 | デーモン自体が管理 |
|
||||
|
||||
日常的な利用では、最初の 2 つだけを直接扱うことになります。**[デーモン](/daemon-runtimes)トークン**は `multica daemon login` が自動的に作成・更新するため、気にする必要はありません。
|
||||
|
||||
## 各トークンがアクセスできるもの
|
||||
|
||||
| API ルート | JWT クッキー | PAT | デーモントークン |
|
||||
|---|---|---|---|
|
||||
| `/api/user/*` (ユーザーレベルの操作) | ✓ | ✓ | ✗ |
|
||||
| `/api/workspaces/:id/*` (ワークスペースレベル) | ✓ | ✓ | ✗ |
|
||||
| `/api/daemon/*` (デーモン専用) | ✗ | ✓ | ✓ |
|
||||
| WebSocket `/ws` (リアルタイムプッシュ) | ✓ (クッキー) | ✓ (最初のメッセージで認証) | ✗ |
|
||||
|
||||
**PAT はほぼすべてにアクセスできます** — これは「完全なあなた」を表します。デーモントークンはデーモンに必要なこと、つまりタスクを取得して結果を報告することしかできません。
|
||||
|
||||
**どちらも `/api/daemon/*` にアクセスできますが、スコープが異なります。** PAT は**ユーザー全体**を表し、一度認証されると、あなたが所属するすべてのワークスペースを見ることができます。デーモントークンは作成時点で単一のワークスペースに固定され、そのワークスペースのリソースにしかアクセスできません。本番環境では、デーモンはデーモントークンで実行してください。手軽さのために PAT を使う近道を選ばないでください。そうしないと、デーモンに必要な以上にはるかに大きな権限を与えてしまいます。
|
||||
|
||||
## ログイン
|
||||
|
||||
### メール + 認証コード
|
||||
|
||||
1. メールアドレスを入力すると、サーバーが 6 桁のコードを送信します。
|
||||
2. コードを入力すると、サーバーが JWT クッキーを発行(ブラウザ)するか、PAT に交換(CLI)します。
|
||||
|
||||
<Callout type="warning">
|
||||
**セルフホストの運用者は注意してください**: 公開デプロイでは `MULTICA_DEV_VERIFICATION_CODE` を空のままにしておいてください。固定のローカルテストコードを有効にすると、`APP_ENV` が production 以外の間は、コードをリクエストできる人なら誰でもその値でサインインできてしまいます。[セルフホスト認証の構成](/auth-setup)を参照してください。
|
||||
</Callout>
|
||||
|
||||
### Google OAuth
|
||||
|
||||
**Sign in with Google** をクリックして、標準の OAuth コールバックを通過してください。セルフホストには `GOOGLE_CLIENT_ID`、`GOOGLE_CLIENT_SECRET`、そしてリダイレクト URI を構成する必要があります — [セルフホスト認証の構成](/auth-setup)を参照してください。
|
||||
|
||||
## PAT の作成、表示、失効
|
||||
|
||||
PAT の**作成**は 2 つの方法で行えます。
|
||||
|
||||
- **Web UI**: 設定 → 個人アクセストークン → 新しいトークン
|
||||
- **CLI**: `multica login` は、まだローカル PAT がない場合に自動的に 1 つ作成します
|
||||
|
||||
<Callout type="warning">
|
||||
**完全な PAT は作成時に正確に 1 回だけ表示されます。** 更新したりダイアログを閉じたりした後は、二度と見ることができません。
|
||||
|
||||
Multica はデータベースに PAT のハッシュだけを保存します — サーバーでさえ元の値を取得できません。すぐにコピーして保存してください。紛失した場合の唯一の手段は、失効させて新しく作り直すことです。
|
||||
</Callout>
|
||||
|
||||
既存の PAT の**表示**(名前、作成時刻、最終使用時刻 — 完全なトークンは**含みません**)は、設定 → 個人アクセストークンにあります。
|
||||
|
||||
PAT の**失効**: 一覧で Revoke をクリックしてください。失効はすぐに反映されます — その PAT で送られる次のリクエストは 401 で拒否されます。
|
||||
|
||||
## ログアウトはローカルトークンを削除するだけ
|
||||
|
||||
`multica auth logout` を実行するか、Web UI でログアウトをクリックすると、
|
||||
|
||||
- **ローカルトークンが消去されます** — CLI は `~/.multica/config.json` から PAT を削除し、ブラウザはクッキーを削除します。
|
||||
- **PAT はサーバー上では依然として有効です** — ログアウトする前に誰かがあなたの PAT を入手していた場合(たとえば別のマシンにコピーしていた場合)、その人は**依然としてそれを使用できます**。
|
||||
|
||||
<Callout type="warning">
|
||||
**PAT が漏洩したと疑われる場合は、単にログアウトするだけにしないでください。** 設定 → 個人アクセストークンに進み、そのトークンを**失効**させてください。失効だけが、漏洩したトークンを即座に無効化します。
|
||||
</Callout>
|
||||
|
||||
## 次のステップ
|
||||
|
||||
- [CLI コマンドリファレンス](/cli) — すべての CLI コマンドの認証は自動です
|
||||
- [セルフホスト認証の構成](/auth-setup) — セルフホスト時にメール、OAuth、サインアップ許可リストを構成する方法
|
||||
- [デーモンとランタイム](/daemon-runtimes) — デーモントークンがどこから来るのか
|
||||
@@ -1,239 +0,0 @@
|
||||
---
|
||||
title: オートパイロット
|
||||
description: エージェントが cron スケジュールやインバウンド webhook で作業を開始したり、UI や CLI で一度だけ手動でトリガーしたりできるようにします。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
オートパイロットは、[エージェント](/agents)が**スケジュールに従って自動的に作業を開始**できるようにします — cron 式とタイムゾーンを設定すると、あなたが何もトリガーしなくても Multica が自ら [`task`](/tasks) をディスパッチします。定期点検、繰り返しのレポート、夜間のクリーンアップ作業など、「常設指示(standing order)」の形の作業に適しています。残りの 3 つのトリガー経路([割り当て](/assigning-issues)、[@-メンション](/mentioning-agents)、[チャット](/chat) — いずれもあなた自身が起点となる方式)と比べたとき、オートパイロットの核心的な違いは**時間駆動**であることです。
|
||||
|
||||
## オートパイロットを構成する
|
||||
|
||||
ワークスペースの**オートパイロット**ページで新しいオートパイロットを作成します。次の項目を設定します。
|
||||
|
||||
- **名前(Name)** — 表示名
|
||||
- **エージェント(Agent)** — 実行をディスパッチする対象
|
||||
- **優先度(Priority)** — 生成される `task` に継承されます(イシューの優先度と同じ意味)
|
||||
- **説明 / プロンプト(Description / prompt)** — 実行のたびにエージェントが受け取る作業説明
|
||||
- **実行モード(Execution mode)** — 以下を参照
|
||||
- **トリガー(Triggers)** — `schedule`(cron + タイムゾーン)または `webhook` のうち少なくとも 1 つ
|
||||
|
||||
## 実行モードを選ぶ
|
||||
|
||||
オートパイロットには 2 つの実行モードがあります。**「イシュー作成」モードから始めてください。**
|
||||
|
||||
- **イシュー作成モード(Create issue mode)**(`create_issue`) — デフォルトであり、**推奨**されます。各トリガーはまずワークスペースにイシューを作成し(タイトルには現在、単一のプレースホルダー `{{date}}` のみがサポートされ、これは `YYYY-MM-DD` 形式の UTC 日付に補間されます。それ以外の `{{...}}` トークンは作成時点で拒否されるため、タイプミスがイシュータイトルにリテラル文字列として静かに紛れ込むことを防ぎます)、通常の割り当てフローを通じてそのイシューをエージェントに割り当てます。すべての作業は、手動で割り当てたイシューと同じ履歴、コメント、ステータスを持った状態でイシューボードに上がります。
|
||||
- **実行専用モード(Run-only mode)**(`run_only`) — イシュー作成をスキップし、`task` を直接キューに入れます。この実行はボードには表示されません — オートパイロットの実行履歴でのみ確認できます。
|
||||
|
||||
## スケジュールに従って実行する
|
||||
|
||||
すべてのオートパイロットには少なくとも 1 つの `schedule` トリガーが必要です。Cron は**標準の 5 フィールド形式**(分 時 日 月 曜日)を使用し、最小単位は **1 分**です(秒単位はありません)。タイムゾーンは IANA 形式(例: `Asia/Shanghai`)で、cron 式がどのタイムゾーンで解釈されるかを決定します。
|
||||
|
||||
いくつかの例:
|
||||
|
||||
- `0 9 * * 1-5`, `Asia/Shanghai` — 平日の北京時間午前 9 時
|
||||
- `*/30 * * * *`, `UTC` — 30 分ごと
|
||||
- `0 3 * * *`, `UTC` — 毎日 UTC 午前 3 時
|
||||
|
||||
Multica サーバーは**30 秒**ごとに期限が来たトリガーをスキャンします — **実際の発火時刻は最大 30 秒まで遅れる可能性があり**、秒単位で正確ではありません。発火時刻のあたりでサーバーが再起動された場合、起動時に逃したトリガーを追いつきます(何も失われませんが、すぐに発火します)。
|
||||
|
||||
## 一度だけ手動でトリガーする
|
||||
|
||||
オートパイロットのデバッグ中に cron を待たないためには、手動でトリガーしてください。
|
||||
|
||||
- UI: オートパイロット詳細ページで「Run now」をクリック
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <autopilot-id>
|
||||
```
|
||||
|
||||
手動トリガーは `schedule` トリガーとまったく同じ実行フローを通り — 実行レコードの `source` フィールドのみが `manual` とマークされます。
|
||||
|
||||
## webhook からトリガーする
|
||||
|
||||
オートパイロットはインバウンドの HTTP webhook でも発火できます。オートパイロット詳細ページで **Webhook** トリガーを追加すると、Multica は次のような形の一意の URL を生成します。
|
||||
|
||||
```
|
||||
https://<your-multica-host>/api/webhooks/autopilots/awt_…
|
||||
```
|
||||
|
||||
その URL に任意の JSON を POST してください — Multica は `source = webhook` で実行を記録し、本文を実行の `trigger_payload` として保存し、schedule トリガーとまったく同じ方法でエージェントをディスパッチします。
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
|
||||
```
|
||||
|
||||
**イシュー作成モード**では、インバウンドの payload が新しいイシューの説明に追記され、エージェントがインラインで読めるようになります。**実行専用モード**では、payload はデーモンがエージェントに渡す実行コンテキストの一部になります。
|
||||
|
||||
### Payload の形
|
||||
|
||||
独自のエンベロープ(envelope)を送れます。
|
||||
|
||||
```json
|
||||
{ "event": "github.pull_request.opened", "eventPayload": { } }
|
||||
```
|
||||
|
||||
…または任意の JSON オブジェクト / 配列を送ることもできます。Multica はこれを内部エンベロープに正規化します。
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "<inferred>",
|
||||
"eventPayload": <your body>,
|
||||
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
|
||||
}
|
||||
```
|
||||
|
||||
`event` フィールドを指定しない場合、Multica は一般的なヘッダーと本文フィールドからこれを推論します(`X-GitHub-Event` + 本文 `action`、`X-Gitlab-Event`、`X-Event-Type`、本文の `event`/`type`/`action`)。どれも一致しない場合、イベントは `webhook.received` になります。
|
||||
|
||||
GitHub のようなソースを構成するときは、content type を `application/json` に設定してください — フォームエンコードされた webhook payload は受け付けられません。
|
||||
|
||||
### イベントフィルター
|
||||
|
||||
新しい webhook トリガーはインバウンドの POST ごとに発火します。単一用途の URL には問題ありませんが、多数のイベントタイプをファンアウトするソース(GitHub が代表的です — 単一のリポジトリ webhook 一つが `push`、`pull_request`、`workflow_run`、`check_suite` などを配信できます)にはノイズになります。webhook トリガーの**イベントフィルター(Event filters)**セクションを使うと、実際に実行をディスパッチするイベントを制限でき、それ以外のすべては `status = ignored`、`reason = event_filtered` で配信履歴に記録され、実行もイシューも作成されません。
|
||||
|
||||
各行は 1 つのルールです。**イベント名(event name)**と、任意でカンマ区切りの **action** リストで構成されます。Multica は**いずれか 1 つ**の行でも一致すれば webhook を許可します。セクションを空のままにすると、すべてを受け付けます(フィルタリング以前の動作)。
|
||||
|
||||
例:
|
||||
|
||||
| イベント名 | Actions | 一致対象 |
|
||||
| -------------- | ------------------- | ------------------------------------------------------------------------ |
|
||||
| `workflow_run` | `completed, failed` | `action: completed` または `action: failed` の `workflow_run` イベントのみ |
|
||||
| `workflow_run` | _(空)_ | action に関係なくすべての `workflow_run` イベント |
|
||||
| `push` | _(空)_ | すべての `push` イベント |
|
||||
|
||||
#### イベント名と action の出所
|
||||
|
||||
Multica は次の順序でインバウンドリクエストから `event` 名と `action` を導き出します — **最初に一致したものが優先されます**。
|
||||
|
||||
**1. 本文エンベロープ(Body envelope)。** 本文が文字列の `event` フィールドを持つ JSON オブジェクトであれば、その値がそのままイベント名になります。任意の `eventPayload` オブジェクトは、自身の `action` / `state` / `conclusion` / `status` フィールドから action 候補を提供します。
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
|
||||
# inferred: event = trigger, action candidate = true
|
||||
```
|
||||
|
||||
**2. ヘッダー(Headers)。** 本文エンベロープがない場合、Multica は次のよく知られたプロバイダーヘッダーを読みます。
|
||||
|
||||
- `X-GitHub-Event: <event>` — (存在する場合)最上位の本文 `action` フィールドと組み合わされて `github.<event>.<action>` を形成します。
|
||||
- `X-Gitlab-Event: <event>` — `gitlab.<event>` になります。
|
||||
- `X-Event-Type: <event>` — そのまま通過します。
|
||||
|
||||
```bash
|
||||
# GitHub-style: header gives the event name, body gives the action.
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-GitHub-Event: workflow_run' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"completed"}'
|
||||
# inferred: event = github.workflow_run.completed
|
||||
# → matches a filter row of workflow_run / completed
|
||||
|
||||
# Generic event-type header — no body fields needed.
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-Event-Type: trigger.true' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{}'
|
||||
# inferred: event = trigger.true → matches trigger / true
|
||||
```
|
||||
|
||||
**3. 本文フォールバック(Body fallback)。** 本文エンベロープも既知のヘッダーもない場合、Multica は次の順序で最上位の本文文字列フィールドにフォールバックします: `event` → `type` → `action`。
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"type":"trigger","action":"true"}'
|
||||
# inferred: event = trigger (from `type`), action candidate = true
|
||||
```
|
||||
|
||||
**4. デフォルト(Default)。** 上記のいずれも一致しない場合、イベントは `webhook.received` で、action 候補はありません。
|
||||
|
||||
**action 候補、全リスト。** イベントが決定されると、Multica は以下のすべての値を可能な action 一致対象として考慮します。
|
||||
|
||||
- イベントが `provider.event.<action>` の形のときのイベント名のサフィックス(例: `github.workflow_run.completed` → `completed`)。
|
||||
- 本文フィールド `action`、`state`、`conclusion`、`status` — **JSON 文字列のときのみ該当します**。ブール値(`{"action": true}`)や数値は資格がないため、`event=trigger, action=true` を期待するフィルターは `{"trigger": true}` の本文とは決して一致しません。`true` は文字列ではなく bool だからです。
|
||||
|
||||
**よくある落とし穴。** `Event name: trigger` / `Actions: true` のようなフィルター行は、「本文に `trigger: true` があれば発火せよ」という意味では**ありません** — イベントフィルターは任意の本文フィールドではなく、*推論されたイベントと action* に一致させます。これにヒットさせるには、`X-Event-Type` で `trigger.true` を送るか(または上に示した本文エンベロープを使ってください)。保存されたフィルター行の周囲の空白(`" workflow_run "`)はそのまま保存され、決して一致しないため — 保存する前に trim してください。
|
||||
|
||||
#### クイックテスト
|
||||
|
||||
フィルターを構成したら、`curl` で両方の分岐を確認できます。
|
||||
|
||||
```bash
|
||||
# Allowed — header drives event=workflow_run, body drives action=completed
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-GitHub-Event: workflow_run' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"completed"}'
|
||||
# → 200 {"status":"accepted", ...}
|
||||
|
||||
# Filtered — same event, action not in allowlist
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-GitHub-Event: workflow_run' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"in_progress"}'
|
||||
# → 200 {"status":"ignored","reason":"event_filtered"}
|
||||
```
|
||||
|
||||
### URL は bearer secret です
|
||||
|
||||
生成された URL **そのものが**認証情報です。それを持っている人は誰でもオートパイロットを発火できます。トークンのように扱ってください。
|
||||
|
||||
- **公開のイシュースレッド、スクリーンショット、チャット履歴に貼り付けないでください。**
|
||||
- **漏洩したら交換してください** — トリガー行で「Rotate URL」をクリックするか、`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>` を実行してください。古い URL はただちに動作を停止します。
|
||||
- 強力なソース認証が必要なソースの場合は、トリガーごとの HMAC 署名検証を待ってください。この v1 URL は bearer 方式のみをサポートします。
|
||||
- 現時点では、オートパイロットを閲覧できるワークスペースメンバーであればその webhook URL を読めます — 役割ごとのより厳格な secret の可視性は後続作業です。
|
||||
|
||||
### ステータスコードの意味
|
||||
|
||||
Multica は正常な no-op の結果に対して `status` フィールド付きで `200 OK` を返すため、プロバイダーの webhook 再試行メカニズムが URL を叩き続けることはありません。
|
||||
|
||||
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}` — 実行がディスパッチされました。
|
||||
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}` — 割り当て先のランタイムがオフラインで、`skipped` の実行として記録されます。
|
||||
- `{"status":"ignored","reason":"trigger_disabled"}` — トリガーが無効になっています。
|
||||
- `{"status":"ignored","reason":"autopilot_paused"}` — オートパイロットが一時停止しています。
|
||||
- `{"status":"ignored","reason":"autopilot_archived"}` — オートパイロットがアーカイブされています。
|
||||
|
||||
2xx 以外の応答は実際の失敗を扱います。
|
||||
|
||||
- `400` — 無効な JSON、スカラー本文、空の本文。
|
||||
- `404` — 不明なトークン(`{"error":"webhook not found"}`)。
|
||||
- `413` — payload が 256 KiB を超えました。
|
||||
- `429` — トークンごとのレート制限超過(デフォルトは 60 req/min)。
|
||||
|
||||
### セルフホスト: 公開 URL を構成する
|
||||
|
||||
サーバーに `MULTICA_PUBLIC_URL` が設定されている場合(例: `https://multica.example.com`)、トリガー応答に絶対パスの `webhook_url` が含まれ、UI にはすぐにコピーできる URL が表示されます。設定しない場合、UI はクライアントの API origin から URL を構成します — デスクトップと同一オリジンの Web には問題ありませんが、カスタムのセルフホストリバースプロキシには適しません。Multica は、誤って構成されたリバースプロキシが攻撃者の制御するホストを指す webhook URL をサーバーに発行させて欺くことができないよう、`Host` / `X-Forwarded-Host` ヘッダーから公開ホストを導出しないよう意図的に設計されています。
|
||||
|
||||
## 実行履歴を見る
|
||||
|
||||
すべてのトリガーは**実行レコード(run record)**を生成し、オートパイロット詳細ページの「History」タブで確認できます。
|
||||
|
||||
- トリガーソース(`schedule` / `manual` / `webhook`)
|
||||
- 開始時刻、完了時刻
|
||||
- ステータス(`issue_created` / `running` / `completed` / `failed` / `skipped`)
|
||||
- 連携したイシュー(イシュー作成モード)または `task`(実行専用モード)
|
||||
- 失敗理由(失敗またはスキップした場合)
|
||||
|
||||
## オートパイロットが失敗したらどうなるか
|
||||
|
||||
<Callout type="warning">
|
||||
**オートパイロットの失敗は自動的に再試行されず、インボックス通知も送られません。** 失敗は実行履歴に `failed` のエントリを残すだけで — 割り当てや @-メンションのようなシステムレベルの再キューイングもなく、誰にも通知が行きません。オートパイロットが定期的な場合、**次の cron 発火が新しい実行をトリガー**しますが、失敗した作業が自動的に再実行されることはありません。
|
||||
|
||||
オートパイロットが重要な場合は、独自のモニタリングを設計してください — 例えば、エージェントに成功時にコメントを残させ、コメントの欠落に気づくことで失敗を検出する、といった具合です。
|
||||
</Callout>
|
||||
|
||||
自動再試行がない理由: オートパイロットはすでに定期的であるため、システムレベルの再試行を追加すると次の予定実行の上に重なり、重複した実行を生み出します。スケジューリングを完全に cron に任せることで、すっきりと保てます。
|
||||
|
||||
## まだ提供されていない機能
|
||||
|
||||
**API 種類のトリガーはまだ接続されていません。** トリガースキーマは `api` 種類を予約していますが、それを発火させるイングレスルートはありません。UI は既存の行に Deprecated バッジを表示し、コピー / 交換の操作は提供しません。トリガーごとの HMAC 署名検証、IP 許可リスト、プロバイダー固有のイベントプリセットは後続作業として追跡されており、v1 URL は bearer 方式のみをサポートします。
|
||||
|
||||
## 次へ
|
||||
|
||||
- [**エージェントにイシューを割り当てる**](/assigning-issues) — イシューをエージェントに一回限りで引き渡す
|
||||
- [**コメントでエージェントを @-メンションする**](/mentioning-agents) — コメントからエージェントを呼んで一度見てもらう
|
||||
- [**チャット**](/chat) — イシューの外での一対一の会話
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
title: チャット
|
||||
description: どのイシューにも属さない、エージェントとの一対一の会話 — 完全にサンドボックス化されています。エージェントはイシューを見たり変更したりできず、他の誰もこの会話を見ることはできません。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
**チャットはあなたと[エージェント](/agents)との一対一の会話です** — [イシュー](/issues)ボードから外に出るものです。エージェントはどのイシューも見られず、どのイシューも変更できず、会話全体は**完全に非公開**です([ワークスペース](/workspaces)内の他の誰も、admin を含めて、この会話を見ることはできません)。エージェントとアプローチを議論したり、ブレインストーミングをしたり、どのイシューにも属さない質問をしたりするのに適しています。
|
||||
|
||||
## エージェントを @-メンションするだけではだめなのですか?
|
||||
|
||||
[@-メンション](/mentioning-agents)はエージェントをイシューのコンテキスト**の中へ引き入れます** — エージェントはイシューの説明とすべての過去のコメントを読み、イシューを変更できます。チャットはこれを逆転させます。**あなたをイシューの外へ引き出します** — エージェントはこの単一の会話のみを見られ、どのイシューの存在も認識せず、イシューを変更する入口もありません。
|
||||
|
||||
2 つの判断基準:
|
||||
|
||||
- 特定のイシューのコンテキストに基づくフィードバックがほしいとき → [@-メンション](/mentioning-agents)
|
||||
- どのイシューとも無関係なトピックを議論したいとき(または他の誰にも議論を見られたくないとき) → チャット
|
||||
|
||||
## 会話を始める
|
||||
|
||||
サイドバーから**チャット**を開き、エージェントを選んで、新しい会話を始めてください。インターフェースはどのメッセージングアプリとも似ています。メッセージを送るとエージェントが返信します。各メッセージはバックグラウンドで実行をトリガーするため(キューに入れられた `task`)、返信には数秒かかることがあります。
|
||||
|
||||
## チャットでエージェントができることとできないこと
|
||||
|
||||
エージェントは会話の中で**完全にサンドボックス化された**モードで実行されます。
|
||||
|
||||
**できること:**
|
||||
|
||||
- 現在のメッセージに含まれる質問に答える
|
||||
- 構成された[スキル](/skills)と MCP を使う
|
||||
- 自身の作業ディレクトリでファイルを読み書きする
|
||||
- イシューコンテキストを必要としない `multica` CLI コマンドを呼び出す(例: 基本的なワークスペース情報の照会)
|
||||
|
||||
**できないこと:**
|
||||
|
||||
- **どのイシューも見ること** — エージェントが受け取るプロンプトにはイシュー ID がなく、`multica issue list` のようなコマンドは空の結果を返します
|
||||
- **どのイシューも変更すること** — イシューコンテキストがなければ、権限チェックによって API 呼び出しがブロックされます
|
||||
- **他の会話を見ること** — 会話は完全に隔離されています
|
||||
- **誰かや任意のエージェントを @-メンションすること** — チャットは他者に通知する経路のない非公開の空間です
|
||||
|
||||
## 複数ターンのコンテキストが保持される仕組み
|
||||
|
||||
チャットは**プロバイダーセッションの再開**を通じて複数ターンのコンテキストを維持します — エージェントは最初の返信でプロバイダーセッションを確立し(例: Claude セッション)、そのセッション ID が保存されます。次のメッセージでは、タスクのディスパッチがその ID を渡し直すため、エージェントは毎回履歴を読み直すことなく**中断したところから再開**します。
|
||||
|
||||
もし**1 つのターンが失敗した**場合、Multica はセッション ID を確立していた以前のタスク(そのタスクが成功したか失敗したかにかかわらず)を探し、再開を試みます — 途中で一度失敗したからといって、会話全体の記憶が失われることはありません。
|
||||
|
||||
注: すべてのプロバイダーが実際にセッション再開を実装しているわけではありません — サポート状況は[**プロバイダーマトリックス**](/providers)を参照してください。
|
||||
|
||||
## 会話をアーカイブする
|
||||
|
||||
もう見たくない会話はアーカイブできます — 会話一覧で右クリックするか、詳細ページの「アーカイブ」ボタンを使ってください。アーカイブ後は次のようになります。
|
||||
|
||||
- 会話がアクティブな一覧から消えます(「アーカイブ済み」ビューで引き続き見つけられます)
|
||||
- 過去のメッセージ、セッション ID、作業ディレクトリはすべて保持されます — 何も削除されません
|
||||
|
||||
<Callout type="warning">
|
||||
**アーカイブ後には「復元」ボタンがありません。** 現在、アーカイブされた会話を再びアクティブな状態に戻す入口はありません。後でそのスレッドを続けたい場合は、新しい会話を始める必要があります。アーカイブされた会話の内容を再び見るには、「アーカイブ済み」ビューを開いて履歴を読んでください。
|
||||
</Callout>
|
||||
|
||||
## 次へ
|
||||
|
||||
- [**オートパイロット**](/autopilots) — エージェントがスケジュールに従って自動的に作業を開始できるようにします
|
||||
- [**エージェントにイシューを割り当てる**](/assigning-issues) — トピックをイシューボードに戻します
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
title: CLI コマンドリファレンス
|
||||
description: すべてのトップレベル Multica CLI コマンドを 1 ページにまとめた概要です。完全な使い方は `multica <command> --help` を実行してください。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica CLI は、Web UI でできるほぼすべての操作をそのまま提供します([イシュー](/issues)の作成、[エージェント](/agents)の割り当て、[デーモン](/daemon-runtimes)の起動など)。このページでは、すべてのトップレベルコマンドを 1 行の説明とともに一覧します。フラグや例の完全な一覧は `multica <command> --help` を実行してください。
|
||||
|
||||
## 認証する
|
||||
|
||||
CLI を初めて使うときにこのコマンドを実行して、**パーソナルアクセストークン(PAT)**を取得します。
|
||||
|
||||
```bash
|
||||
multica login
|
||||
```
|
||||
|
||||
ブラウザが自動的に開きます。Web アプリで承認すると、CLI が PAT(`mul_` プレフィックス付き)を `~/.multica/config.json` に保存します。これ以降のすべてのコマンドはこの PAT で認証されます。
|
||||
|
||||
<Callout type="tip">
|
||||
CI やヘッドレス環境では、ブラウザフローをスキップできます。Web アプリの **Settings → Personal Access Tokens** で PAT を作成し、`multica login --token <mul_...>` で直接渡してください。
|
||||
</Callout>
|
||||
|
||||
トークンの種類による違いについては、[認証とトークン](/auth-tokens)を参照してください。
|
||||
|
||||
## 認証とセットアップ
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `multica login` | ログインして PAT を保存 |
|
||||
| `multica auth status` | 現在のログイン状態、ユーザー、ワークスペースを表示 |
|
||||
| `multica auth logout` | ローカルの PAT を削除 |
|
||||
| `multica setup cloud` | Multica Cloud のワンショットセットアップ(ログイン + デーモンのインストール) |
|
||||
| `multica setup self-host` | セルフホストバックエンドのワンショットセットアップ |
|
||||
|
||||
## ワークスペースとメンバー
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `multica workspace list` | アクセスできるすべてのワークスペースを一覧 |
|
||||
| `multica workspace get <slug>` | 1 つのワークスペースの詳細を表示 |
|
||||
| `multica workspace member list` | 現在のワークスペースのメンバーを一覧 |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | ワークスペースのメタデータを更新(admin/owner)。長いフィールドは `--description-stdin` / `--context-stdin` を使用できます。 |
|
||||
|
||||
## イシューとプロジェクト
|
||||
|
||||
<Callout type="info">
|
||||
`list` 系のコマンド(`multica issue list`、`autopilot list`、`project list` など)は、デフォルトで短く**そのままコピー&ペーストできる** ID を出力します。イシューは `MUL-123` のようなイシューキー、それ以外のリソースは短い UUID プレフィックスです。以下の後続コマンドの `<id>` 引数は短い ID と完全な UUID のどちらも受け取るため、一般的な流れは `multica issue list` → キーをコピー → `multica issue get MUL-123` となります。正式な UUID が必要なときは `list` コマンドに `--full-id` を渡してください。
|
||||
</Callout>
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `multica issue list` | イシューを一覧(コピー&ペーストできるイシューキーを出力) |
|
||||
| `multica issue get <id>` | 単一のイシューを表示(イシューキーまたは UUID を受け取る) |
|
||||
| `multica issue create --title "..."` | 新しいイシューを作成 |
|
||||
| `multica issue update <id> ...` | イシューを更新(ステータス、優先度、担当者など) |
|
||||
| `multica issue assign <id> --to <name>` | メンバー、エージェント、またはスクワッドに割り当て(エージェントへの割り当ては実行をトリガー) |
|
||||
| `multica issue status <id> <status>` | ステータス変更のショートカット |
|
||||
| `multica issue search <query>` | キーワード検索 |
|
||||
| `multica issue children <id>` | サブイシューを stage ごとに一覧 |
|
||||
| `multica issue pull-requests <id>` | 紐付いた pull request と状態を一覧 |
|
||||
| `multica issue runs <id>` | イシュー上のエージェント実行を表示 |
|
||||
| `multica issue run-messages <task-id>` | 1 回の実行メッセージを表示 |
|
||||
| `multica issue usage <id>` | イシュー単位の集計 token 使用量を表示 |
|
||||
| `multica issue rerun <id>` | イシューの現在のエージェント担当者向けに新しいタスクを再キューイング |
|
||||
| `multica issue cancel-task <task-id>` | キュー中または実行中のタスクをキャンセル |
|
||||
| `multica issue comment <id> ...` | ネスト: コメントの表示 / 投稿 |
|
||||
| `multica issue comment resolve/unresolve <comment-id>` | コメントスレッドを解決済み / 未解決にする |
|
||||
| `multica issue subscriber <id> ...` | ネスト: 購読 / 購読解除 |
|
||||
| `multica issue metadata <id> ...` | ネスト: イシューメタデータの読み書き |
|
||||
| `multica issue label <id> ...` | ネスト: イシューのラベルを管理 |
|
||||
| `multica project list/get/create/update/delete/status` | プロジェクトの CRUD |
|
||||
| `multica label list/create/update/delete` | ワークスペースラベルの CRUD |
|
||||
|
||||
## エージェントとスキル
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `multica agent list` | ワークスペースのエージェントを一覧 |
|
||||
| `multica agent get <slug>` | エージェントの構成を表示 |
|
||||
| `multica agent create ...` | エージェントを作成 |
|
||||
| `multica agent update <slug> ...` | エージェントを更新 |
|
||||
| `multica agent archive <slug>` | アーカイブ |
|
||||
| `multica agent restore <slug>` | アーカイブ済みのエージェントを復元 |
|
||||
| `multica agent tasks <slug>` | エージェントのタスク履歴を表示 |
|
||||
| `multica agent avatar <slug>` | エージェントのアバターをアップロード |
|
||||
| `multica agent env <slug> ...` | エージェントの custom environment variables を読み取り / 置換 |
|
||||
| `multica agent skills ...` | ネスト: スキルのアタッチ / デタッチ |
|
||||
| `multica skill list/get/create/update/delete` | スキルの CRUD |
|
||||
| `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート |
|
||||
| `multica skill files ...` | ネスト: スキルのファイルを管理 |
|
||||
|
||||
### スキルインポートの競合
|
||||
|
||||
`multica skill import --url <url>` の既定値は `--on-conflict fail` です。同じ名前のスキルがすでに存在する場合、コマンドは構造化された `conflict` 結果で終了し、ワークスペースは変更されません。
|
||||
|
||||
既存スキルの作成者で、スキル ID とエージェントの紐付けを維持したまま内容を置き換える場合は `--on-conflict overwrite` を使います。既存スキルを残してコピーを取り込む場合は `--on-conflict rename` を使うと、`-2` のような接尾辞が自動で付きます。同名の項目を単に飛ばす場合は `--on-conflict skip` を使います。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## スクワッド
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `multica squad list` | ワークスペースのスクワッドを一覧 |
|
||||
| `multica squad get <id>` | 単一のスクワッドを表示 |
|
||||
| `multica squad create --name "..." --leader <agent>` | スクワッドを作成(owner / admin) |
|
||||
| `multica squad update <id> ...` | 名前、説明、指示、リーダー、またはアバターを更新 |
|
||||
| `multica squad delete <id>` | アーカイブ(ソフト削除) — 割り当て済みのイシューをリーダーに移管 |
|
||||
| `multica squad member list/add/remove <squad-id>` | スクワッドメンバーを管理 |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | スクワッドリーダーエージェントがターンごとに評価を記録するために使用 |
|
||||
|
||||
完全なモデルについては[スクワッド](/squads)を参照してください。
|
||||
|
||||
## オートパイロット
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `multica autopilot list` | ワークスペースのすべてのオートパイロットを一覧 |
|
||||
| `multica autopilot get <id>` | 単一のオートパイロットを表示 |
|
||||
| `multica autopilot create ...` | オートパイロットを作成 |
|
||||
| `multica autopilot update <id> ...` | 更新 |
|
||||
| `multica autopilot delete <id>` | 削除 |
|
||||
| `multica autopilot runs <id>` | 実行履歴を表示 |
|
||||
| `multica autopilot trigger <id>` | 手動で実行をトリガー |
|
||||
| `multica autopilot trigger-add/update/delete <id>` | schedule または webhook trigger を管理 |
|
||||
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | webhook trigger URL をローテート |
|
||||
|
||||
`multica autopilot create/update` は、`create_issue` モードの autopilot が作成するイシューの既定購読者を設定する `--subscriber` も受け取ります。`update` では `--clear-subscribers` で削除できます。
|
||||
|
||||
## デーモンとランタイム
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `multica daemon start` | デーモンを起動(デフォルトはバックグラウンド。`--foreground` を追加するとフォアグラウンドで実行) |
|
||||
| `multica daemon stop` | デーモンを停止 |
|
||||
| `multica daemon restart` | デーモンを再起動 |
|
||||
| `multica daemon status` | デーモンがオンラインかどうかと同時実行数を確認 |
|
||||
| `multica daemon logs` | デーモンのログを表示 |
|
||||
| `multica runtime list` | 現在のワークスペースのランタイムを一覧 |
|
||||
| `multica runtime usage` | リソース使用量を表示 |
|
||||
| `multica runtime activity` | 最近のアクティビティログ |
|
||||
| `multica runtime update <id> ...` | ランタイム上で CLI 更新を開始 |
|
||||
| `multica runtime delete <id> [--cascade]` | ランタイムを削除し、必要なら紐付くエージェントもアーカイブ |
|
||||
| `multica runtime profile ...` | カスタム runtime profile とローカルパス上書きを管理 |
|
||||
|
||||
## その他
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `multica repo checkout <url>` | エージェントが使用できるようにリポジトリをローカルにクローン |
|
||||
| `multica config` | ローカルの CLI 構成を表示または編集 |
|
||||
| `multica version` | CLI のバージョンを出力 |
|
||||
| `multica update` | CLI を最新のリリースにアップグレード |
|
||||
| `multica attachment download <id>` | イシューまたはコメントから添付ファイルをダウンロード |
|
||||
|
||||
## 完全なフラグを確認する
|
||||
|
||||
すべてのコマンドが `--help` をサポートしています。
|
||||
|
||||
```bash
|
||||
multica issue create --help
|
||||
multica agent update --help
|
||||
```
|
||||
|
||||
v2 では、各コマンドごとに専用の詳細なリファレンスページを提供する予定です。
|
||||
|
||||
## 次のステップ
|
||||
|
||||
- [認証とトークン](/auth-tokens) — PAT vs. JWT vs. デーモントークン
|
||||
- [デーモンとランタイム](/daemon-runtimes) — `daemon` コマンドが内部でどう動作するか
|
||||
- [エージェントの作成と構成](/agents-create) — `multica agent create` のすべてのオプション
|
||||
@@ -54,23 +54,14 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
|
||||
| `multica issue get <id>` | 단일 이슈 표시(이슈 키 또는 UUID를 받음) |
|
||||
| `multica issue create --title "..."` | 새 이슈 생성 |
|
||||
| `multica issue update <id> ...` | 이슈 업데이트(상태, 우선순위, 담당자 등) |
|
||||
| `multica issue assign <id> --to <name>` | 멤버, 에이전트, 또는 스쿼드에 할당(에이전트에 할당하면 실행이 트리거됨) |
|
||||
| `multica issue status <id> <status>` | 상태 변경 단축 명령 |
|
||||
| `multica issue assign <id> --agent <slug>` | 에이전트에게 할당(즉시 작업을 트리거) |
|
||||
| `multica issue status <id> --set <status>` | 상태 변경 단축 명령 |
|
||||
| `multica issue search <query>` | 키워드 검색 |
|
||||
| `multica issue children <id>` | 하위 이슈를 stage별로 나열 |
|
||||
| `multica issue pull-requests <id>` | 연결된 pull request와 상태 나열 |
|
||||
| `multica issue runs <id>` | 이슈의 에이전트 실행 표시 |
|
||||
| `multica issue run-messages <task-id>` | 한 실행의 메시지 표시 |
|
||||
| `multica issue usage <id>` | 이슈의 집계 token 사용량 표시 |
|
||||
| `multica issue rerun <id>` | 이슈의 현재 에이전트 담당자에게 새 작업을 다시 큐에 넣기 |
|
||||
| `multica issue cancel-task <task-id>` | 대기 중이거나 실행 중인 작업 취소 |
|
||||
| `multica issue comment <id> ...` | 중첩: 댓글 보기 / 작성 |
|
||||
| `multica issue comment resolve/unresolve <comment-id>` | 댓글 스레드를 해결 / 미해결로 표시 |
|
||||
| `multica issue subscriber <id> ...` | 중첩: 구독 / 구독 취소 |
|
||||
| `multica issue metadata <id> ...` | 중첩: 이슈 metadata 읽기 / 쓰기 |
|
||||
| `multica issue label <id> ...` | 중첩: 이슈의 label 관리 |
|
||||
| `multica project list/get/create/update/delete/status` | 프로젝트 CRUD |
|
||||
| `multica label list/create/update/delete` | 워크스페이스 label CRUD |
|
||||
|
||||
## 에이전트와 스킬
|
||||
|
||||
@@ -83,26 +74,11 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
|
||||
| `multica agent archive <slug>` | 보관 |
|
||||
| `multica agent restore <slug>` | 보관된 에이전트 복원 |
|
||||
| `multica agent tasks <slug>` | 에이전트의 작업 기록 표시 |
|
||||
| `multica agent avatar <slug>` | 에이전트 아바타 업로드 |
|
||||
| `multica agent env <slug> ...` | 에이전트 custom environment variables 읽기 / 교체 |
|
||||
| `multica agent skills ...` | 중첩: 스킬 연결 / 분리 |
|
||||
| `multica skill list/get/create/update/delete` | 스킬 CRUD |
|
||||
| `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 |
|
||||
| `multica skill files ...` | 중첩: 스킬의 파일 관리 |
|
||||
|
||||
### 스킬 가져오기 충돌
|
||||
|
||||
`multica skill import --url <url>`의 기본값은 `--on-conflict fail`입니다. 같은 이름의 스킬이 이미 있으면 명령은 구조화된 `conflict` 결과로 종료되며 워크스페이스를 변경하지 않습니다.
|
||||
|
||||
기존 스킬을 만든 사용자이고, 스킬 ID와 에이전트 연결은 유지한 채 내용을 바꾸려면 `--on-conflict overwrite`를 사용하세요. 기존 스킬을 그대로 두고 복사본을 가져오려면 `--on-conflict rename`을 사용하면 `-2` 같은 접미사가 자동으로 붙습니다. 같은 이름의 항목을 그냥 건너뛰려면 `--on-conflict skip`을 사용하세요.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 스쿼드
|
||||
|
||||
| 명령어 | 용도 |
|
||||
@@ -128,10 +104,6 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
|
||||
| `multica autopilot delete <id>` | 삭제 |
|
||||
| `multica autopilot runs <id>` | 실행 기록 표시 |
|
||||
| `multica autopilot trigger <id>` | 수동으로 실행 트리거 |
|
||||
| `multica autopilot trigger-add/update/delete <id>` | schedule 또는 webhook trigger 관리 |
|
||||
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | webhook trigger URL 회전 |
|
||||
|
||||
`multica autopilot create/update`는 `create_issue` 모드 autopilot이 만드는 이슈의 기본 구독자를 설정하는 `--subscriber`도 받습니다. `update`에서는 `--clear-subscribers`로 제거할 수 있습니다.
|
||||
|
||||
## 데몬과 런타임
|
||||
|
||||
@@ -145,9 +117,7 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
|
||||
| `multica runtime list` | 현재 워크스페이스의 런타임 나열 |
|
||||
| `multica runtime usage` | 리소스 사용량 표시 |
|
||||
| `multica runtime activity` | 최근 활동 로그 |
|
||||
| `multica runtime update <id> ...` | 런타임에서 CLI 업데이트 시작 |
|
||||
| `multica runtime delete <id> [--cascade]` | 런타임 삭제, 필요하면 연결된 에이전트도 보관 |
|
||||
| `multica runtime profile ...` | 사용자 지정 runtime profile과 로컬 경로 override 관리 |
|
||||
| `multica runtime update <id> ...` | 런타임의 구성 업데이트 |
|
||||
|
||||
## 기타
|
||||
|
||||
|
||||
@@ -54,23 +54,14 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica issue get <id>` | Show a single issue (accepts an issue key or a UUID) |
|
||||
| `multica issue create --title "..."` | Create a new issue |
|
||||
| `multica issue update <id> ...` | Update an issue (status, priority, assignee, etc.) |
|
||||
| `multica issue assign <id> --to <name>` | Assign to a member, agent, or squad (assigning to an agent triggers a run) |
|
||||
| `multica issue status <id> <status>` | Shortcut to change status |
|
||||
| `multica issue assign <id> --agent <slug>` | Assign to an agent (triggers a task immediately) |
|
||||
| `multica issue status <id> --set <status>` | Shortcut to change status |
|
||||
| `multica issue search <query>` | Keyword search |
|
||||
| `multica issue children <id>` | List sub-issues grouped by stage |
|
||||
| `multica issue pull-requests <id>` | List linked pull requests and their status |
|
||||
| `multica issue runs <id>` | Show agent runs on an issue |
|
||||
| `multica issue run-messages <task-id>` | Show messages for one execution |
|
||||
| `multica issue usage <id>` | Show aggregated token usage for an issue |
|
||||
| `multica issue rerun <id>` | Re-enqueue a fresh task for the issue's current agent assignee |
|
||||
| `multica issue cancel-task <task-id>` | Cancel a queued or running task |
|
||||
| `multica issue comment <id> ...` | Nested: view / post comments |
|
||||
| `multica issue comment resolve/unresolve <comment-id>` | Mark a comment thread resolved or unresolved |
|
||||
| `multica issue subscriber <id> ...` | Nested: subscribe / unsubscribe |
|
||||
| `multica issue metadata <id> ...` | Nested: read / write issue metadata |
|
||||
| `multica issue label <id> ...` | Nested: manage labels on an issue |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
| `multica label list/create/update/delete` | Workspace label CRUD |
|
||||
|
||||
## Agents and skills
|
||||
|
||||
@@ -83,32 +74,11 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica agent archive <slug>` | Archive |
|
||||
| `multica agent restore <slug>` | Restore an archived agent |
|
||||
| `multica agent tasks <slug>` | Show an agent's task history |
|
||||
| `multica agent avatar <slug>` | Upload an agent avatar |
|
||||
| `multica agent env <slug> ...` | Read or replace an agent's custom environment variables |
|
||||
| `multica agent skills ...` | Nested: attach / detach skills |
|
||||
| `multica skill list/get/create/update/delete` | Skill CRUD |
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
### Skill import conflicts
|
||||
|
||||
`multica skill import --url <url>` defaults to `--on-conflict fail`. If a skill
|
||||
with the same name already exists, the command exits with a structured
|
||||
`conflict` result and does not change the workspace.
|
||||
|
||||
Use `--on-conflict overwrite` when you created the existing skill and want to
|
||||
replace its content while preserving its ID and agent bindings. Use
|
||||
`--on-conflict rename` to import a copy with an automatic suffix such as `-2`.
|
||||
Use `--on-conflict skip` to leave the existing skill untouched and report
|
||||
`skipped`.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## Squads
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -134,12 +104,6 @@ See [Squads](/squads) for the full model.
|
||||
| `multica autopilot delete <id>` | Delete |
|
||||
| `multica autopilot runs <id>` | Show run history |
|
||||
| `multica autopilot trigger <id>` | Trigger a run manually |
|
||||
| `multica autopilot trigger-add/update/delete <id>` | Manage schedule or webhook triggers |
|
||||
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | Rotate a webhook trigger URL |
|
||||
|
||||
`multica autopilot create/update` also accepts `--subscriber` to set default
|
||||
subscribers for issues created by a `create_issue` autopilot; update accepts
|
||||
`--clear-subscribers` to remove them.
|
||||
|
||||
## Daemon and runtimes
|
||||
|
||||
@@ -153,9 +117,7 @@ subscribers for issues created by a `create_issue` autopilot; update accepts
|
||||
| `multica runtime list` | List runtimes in the current workspace |
|
||||
| `multica runtime usage` | Show resource usage |
|
||||
| `multica runtime activity` | Recent activity log |
|
||||
| `multica runtime update <id> ...` | Initiate a CLI update on a runtime |
|
||||
| `multica runtime delete <id> [--cascade]` | Delete a runtime, optionally archiving bound agents |
|
||||
| `multica runtime profile ...` | Manage custom runtime profiles and local path overrides |
|
||||
| `multica runtime update <id> ...` | Update a runtime's configuration |
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
|
||||
@@ -54,23 +54,14 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica issue get <id>` | 查看单条 issue(接受 issue key 或 UUID) |
|
||||
| `multica issue create --title "..."` | 创建新 issue |
|
||||
| `multica issue update <id> ...` | 修改 issue(状态、优先级、分配人等) |
|
||||
| `multica issue assign <id> --to <name>` | 分配给成员、智能体或小队(分配给智能体会触发一次运行) |
|
||||
| `multica issue status <id> <status>` | 快捷改状态 |
|
||||
| `multica issue assign <id> --agent <slug>` | 分配给智能体(立即触发任务) |
|
||||
| `multica issue status <id> --set <status>` | 快捷改状态 |
|
||||
| `multica issue search <query>` | 关键字搜索 |
|
||||
| `multica issue children <id>` | 按 stage 分组查看子 issue |
|
||||
| `multica issue pull-requests <id>` | 查看关联 PR 及其状态 |
|
||||
| `multica issue runs <id>` | 查看 issue 上智能体跑过的任务 |
|
||||
| `multica issue run-messages <task-id>` | 查看某次执行的消息 |
|
||||
| `multica issue usage <id>` | 查看单个 issue 聚合 token 用量 |
|
||||
| `multica issue rerun <id>` | 给该 issue 当前的智能体分配人重新创建一条任务 |
|
||||
| `multica issue cancel-task <task-id>` | 取消排队中或运行中的任务 |
|
||||
| `multica issue comment <id> ...` | 嵌套:看 / 发评论 |
|
||||
| `multica issue comment resolve/unresolve <comment-id>` | 标记评论线程已解决 / 未解决 |
|
||||
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
|
||||
| `multica issue metadata <id> ...` | 嵌套:读写 issue metadata |
|
||||
| `multica issue label <id> ...` | 嵌套:管理 issue 上的 label |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
| `multica label list/create/update/delete` | Workspace label CRUD |
|
||||
|
||||
## 智能体和 Skill
|
||||
|
||||
@@ -83,26 +74,11 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica agent archive <slug>` | 归档 |
|
||||
| `multica agent restore <slug>` | 恢复归档的智能体 |
|
||||
| `multica agent tasks <slug>` | 查看智能体的任务历史 |
|
||||
| `multica agent avatar <slug>` | 上传智能体头像 |
|
||||
| `multica agent env <slug> ...` | 读取或替换智能体的 custom environment variables |
|
||||
| `multica agent skills ...` | 嵌套:挂载 / 卸载 Skill |
|
||||
| `multica skill list/get/create/update/delete` | Skill CRUD |
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
### Skill 导入冲突
|
||||
|
||||
`multica skill import --url <url>` 默认等同于 `--on-conflict fail`。如果工作区里已经有同名 Skill,命令会返回结构化 `conflict` 结果并退出,不会修改工作区。
|
||||
|
||||
如果你是已有 Skill 的 creator,并且想用新导入内容覆盖它,同时保留原 Skill 的 ID 和 agent 绑定,用 `--on-conflict overwrite`。如果想保留已有 Skill、另存一份,用 `--on-conflict rename`,系统会自动加 `-2` 这类后缀。如果只是批量导入时遇到同名项就跳过,用 `--on-conflict skip`。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 小队
|
||||
|
||||
| 命令 | 用途 |
|
||||
@@ -128,10 +104,6 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
|
||||
| `multica autopilot delete <id>` | 删除 |
|
||||
| `multica autopilot runs <id>` | 查看运行历史 |
|
||||
| `multica autopilot trigger <id>` | 手动触发一次 |
|
||||
| `multica autopilot trigger-add/update/delete <id>` | 管理 schedule 或 webhook trigger |
|
||||
| `multica autopilot trigger-rotate-url <id> <trigger-id>` | 轮换 webhook trigger URL |
|
||||
|
||||
`multica autopilot create/update` 还支持 `--subscriber`,为 `create_issue` 模式创建的 issue 设置默认订阅人;`update` 支持 `--clear-subscribers` 清空默认订阅人。
|
||||
|
||||
## 守护进程和运行时
|
||||
|
||||
@@ -145,9 +117,7 @@ multica skill import --url https://skills.sh/acme/repo/review-helper --on-confli
|
||||
| `multica runtime list` | 列出当前工作区的 runtime |
|
||||
| `multica runtime usage` | 查看资源使用情况 |
|
||||
| `multica runtime activity` | 近期活动记录 |
|
||||
| `multica runtime update <id> ...` | 在某个 runtime 上触发 CLI 更新 |
|
||||
| `multica runtime delete <id> [--cascade]` | 删除 runtime,可选择同时归档绑定的智能体 |
|
||||
| `multica runtime profile ...` | 管理自定义 runtime profile 和本机路径覆盖 |
|
||||
| `multica runtime update <id> ...` | 更新 runtime 配置 |
|
||||
|
||||
## 杂项
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ Daemon behavior is configured via flags or environment variables:
|
||||
|---------|------|--------------|---------|
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `0`(不限制,由看门狗兜底)|
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
title: Cloud クイックスタート
|
||||
description: サインアップからエージェントへの最初のタスク割り当てまで 5 分で。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
このページは Multica Cloud を最初から最後まで案内します — **サインアップ → [CLI](/cli) のインストール → [デーモン](/daemon-runtimes)の起動 → [エージェント](/agents)の作成 → 最初の[タスク](/tasks)の割り当て**。約 5 分かかります。
|
||||
|
||||
前提条件は 1 つだけです: ローカルに [AI コーディングツール](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) のいずれか)を少なくとも 1 つ、すでにインストールしておくこと。デーモンは起動時にこれらを自動検出し、1 つもなければ起動を拒否します。
|
||||
|
||||
## 1. アカウントを作成する
|
||||
|
||||
[multica.ai](https://multica.ai) でサインアップしてください。メール(6 桁の確認コード)または Google でログインできます。
|
||||
|
||||
サインアップ後は(アカウント名から生成された)デフォルトのワークスペースに自動的に配置されます。後で名前を変更したり、新しいワークスペースを作成したりできます。
|
||||
|
||||
## 2. Multica CLI をインストールする
|
||||
|
||||
**macOS / Linux(Homebrew 推奨)**:
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
**macOS / Linux(Homebrew なし)**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows(PowerShell)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
インストールを確認します。
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
## 3. ログイン + デーモンの起動
|
||||
|
||||
コマンド 1 つでログインとデーモンの起動を処理します。
|
||||
|
||||
```bash
|
||||
multica setup
|
||||
```
|
||||
|
||||
`multica setup` は次を実行します。
|
||||
|
||||
1. CLI が Multica Cloud に接続するよう構成します
|
||||
2. ログインのためにブラウザを開きます(Web と同じメール確認コード / Google OAuth)
|
||||
3. 生成された PAT を `~/.multica/config.json` に保存します
|
||||
4. **デーモンを自動的に起動します** — 3 秒ごとにタスクをポーリングし、15 秒ごとにハートビートを送信し始めます
|
||||
|
||||
<Callout type="info">
|
||||
**デスクトップアプリを使用していますか?** デスクトップアプリは起動時に**デーモンを自動的に起動します** — `multica setup` を手動で実行する必要はありません。[デスクトップアプリ](/desktop-app)を参照してください。
|
||||
</Callout>
|
||||
|
||||
デーモンが実行中かどうかを確認します。
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
`online` はサーバーに登録されたことを意味します。
|
||||
|
||||
## 4. ランタイムがオンラインか確認する
|
||||
|
||||
Web UI で **Settings → Runtimes** に移動します。先ほど起動したデーモンが、1 つ以上のアクティブなランタイムとして表示されるはずです — ローカルにインストールされた AI コーディングツールごとに 1 つです。
|
||||
|
||||
オフラインと表示されても慌てないでください — [トラブルシューティング → デーモンがサーバーに接続できない](/troubleshooting#daemon-cant-connect-to-the-server)を参照してください。
|
||||
|
||||
## 5. エージェントを作成する
|
||||
|
||||
Web UI で **Settings → Agents** に移動し、**New Agent** をクリックします。
|
||||
|
||||
- **Name** — ボードやコメントでこのエージェントに表示される名前です。好きな名前を選んでください
|
||||
- **Provider** — ローカルにインストールした AI コーディングツールを選択します(ドロップダウンにはランタイムで検出されたツールのみが表示されます)
|
||||
- **Model**(任意) — そのツール内部のモデル選択(プロバイダーによって静的な一覧または動的探索)
|
||||
- **Instructions**(任意) — このエージェントのためのシステムプロンプト
|
||||
|
||||
作成されると、エージェントはワークスペースのメンバー一覧に表示され、人間のメンバーと同じように作業を割り当てられます。
|
||||
|
||||
## 6. 最初のタスクを割り当てる
|
||||
|
||||
Web UI でイシューを作成するか、CLI から作成します。
|
||||
|
||||
```bash
|
||||
multica issue create --title "Add an ASCII architecture diagram to the README"
|
||||
```
|
||||
|
||||
先ほど作成したエージェントにイシューを割り当てます — Web UI でアバターをクリックするか、CLI を使用します。
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` はエージェントまたはメンバーの**名前**を受け取ります。部分文字列の一致も機能します — エージェント名が `my-code-reviewer` なら、`reviewer` でそれに解決されます。ワークスペースに名前が重複している場合は、代わりに `--to-id <uuid>`(`--to` と相互排他)を渡してください。UUID は `multica agent list --output json` または `multica workspace member list --output json` で調べられます。
|
||||
|
||||
**次にデーモンで起きること**:
|
||||
|
||||
1. 3 秒以内にタスクを取得します(ステータスが `queued` から `dispatched` に変わります)
|
||||
2. 一致する AI コーディングツールを呼び出して作業を開始します(ステータスが `running` になります)
|
||||
3. AI がローカルで作業します — コードディレクトリを読んだり、コマンドを実行したり、ファイルを編集したりできます
|
||||
4. 完了すると結果を Multica に報告します(自動リトライが作動するかどうかに応じて、ステータスが `completed` または `failed` になります)
|
||||
|
||||
Web UI は**リアルタイムで**(WebSocket を通じて)更新されます — 再読み込みは不要です。
|
||||
|
||||
## 次のステップ
|
||||
|
||||
- [デーモンとランタイム](/daemon-runtimes) — デーモンがどう動作するかとランタイムの意味
|
||||
- [タスク](/tasks) — タスクのライフサイクルとリトライルール
|
||||
- [AI コーディングツール比較](/providers) — 12 個のツール間の機能差
|
||||
- [デスクトップアプリ](/desktop-app) — デーモンを自分で実行したくない場合
|
||||
- [セルフホストクイックスタート](/self-host-quickstart) — 自前のバックエンドを実行する
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: コメントとメンション
|
||||
description: イシューの下での共同作業 — コメント、返信、`@` メンション、リアクション、そしてコメントからエージェントをトリガーする方法。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
すべての[イシュー](/issues)にはコメントスレッドがあります。コメントを投稿し、誰かに返信し、[メンバー](/members-roles)や[エージェント](/agents)を `@` でメンションし、リアクションを追加する — これまで使ってきたどのタスク管理ツールでも行ってきたのと同じ操作です。唯一の違いは、**`@` でエージェントをメンションすると、そのエージェントが作業を開始するようトリガーされる**ことです。
|
||||
|
||||
## コメントを投稿する
|
||||
|
||||
イシュー詳細ページ下部の入力欄に内容を入力し、**送信**を押してください。コメントはすぐにスレッドに表示されます。コメントは Markdown に対応しています — 見出し、リスト、コードブロック、リンクがすべて使えます。
|
||||
|
||||
## コメントに返信する
|
||||
|
||||
任意のコメントの右上にある**返信**をクリックすると、その下にネストされた入力欄が開きます。返信はそのコメントの子要素として表示され、会話スレッドを形成します。返信にもさらに返信を付けられ、必要なだけ深くネストできます。
|
||||
|
||||
イシュー一覧にはトップレベルのコメント数だけが表示され、イシューを開くと会話ツリー全体が見えます。
|
||||
|
||||
## リアクション
|
||||
|
||||
各コメントの右上には、素早く意思を伝えるためのリアクションボタンがあります(👍、👀、🎉)— 同意を示すために「+1」コメントをわざわざ投稿する必要はありません。
|
||||
|
||||
## `@` メンション
|
||||
|
||||
コメントに `@` を入力するとピッカーが開きます。メンバーまたはエージェントを選ぶと、`@` と対象のスラッグが挿入されます(`@alice` や `@reviewer-bot`)。メンションされた相手は自分の[インボックス](/inbox)に通知を受け取ります。
|
||||
|
||||
**エージェントをメンションすると自動的にトリガーされます** — [コメントでエージェントをメンションする](/mentioning-agents)を参照してください。
|
||||
|
||||
1 つのコメントで同じ人を複数回メンションしても、通知は**1 つだけ**発生します。
|
||||
|
||||
### `@all` はワークスペース全体に通知する
|
||||
|
||||
`@all` は特別な対象です。ワークスペースのすべてのメンバーに通知を送ります。人もエージェントも `@all` を使えます — つまり進捗を報告するエージェントも `@all` できるので、エージェントの指示には控えめに使うよう伝えておきましょう。
|
||||
|
||||
<Callout type="warning">
|
||||
**`@all` は慎重に使ってください。** 規模の大きいワークスペースでは、たった 1 回の `@all` がその人数分のインボックス通知を瞬時に生成します。全員が本当に知る必要があることだけに使い、日常的な更新には使わないでください。
|
||||
</Callout>
|
||||
|
||||
## イシューを参照する
|
||||
|
||||
別のイシューをリンクするには、コメントの mention ピッカーからそのイシューを選択してください。Multica はイシューリンクを明示的な `[MUL-123](mention://issue/<uuid>)` mention リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
|
||||
|
||||
`MUL-123` のような裸のイシューキーを入力しても、通常のテキストのまま残ります。そのため、`feature/MUL-123` のようなコメント内のブランチ名やパスも書き換えられません。
|
||||
|
||||
<Callout type="info">
|
||||
Markdown の強調は CommonMark のルールに従います。太字テキストが句読点や閉じ引用符で終わり、その直後に韓国語の助詞が続く場合、閉じの `**` が認識されないことがあります。
|
||||
|
||||
引用符を太字の範囲の外に出すことをおすすめします。
|
||||
|
||||
```markdown
|
||||
"**무엇을 먼저 정해두고 시작할지**"가
|
||||
```
|
||||
|
||||
次の代わりに:
|
||||
|
||||
```markdown
|
||||
**"무엇을 먼저 정해두고 시작할지"**가
|
||||
```
|
||||
</Callout>
|
||||
|
||||
## コメントの編集と削除
|
||||
|
||||
コメントは作成者のみが編集または削除できます。
|
||||
|
||||
コメントを削除すると、その下の**すべての返信も一緒に削除されます**(返信への返信も含む)。内容だけを変えたい場合は、削除ではなく編集を使ってください。
|
||||
|
||||
<Callout type="warning">
|
||||
**コメントを編集して `@` を追加しても、エージェントはトリガーされません。** トリガーはコメントが**作成された**その瞬間に発生します — 後から編集して新しい `@` を追加したり、対象を変えたりしても、新しい通知は送られず、エージェントも起きません。見逃したエージェントを呼び出すには、そのエージェントを `@` する**新しいコメントを投稿**してください。
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
ここまで扱ってきた内容はすべて「人の世界」です — ワークスペース、メンバー、イシュー、プロジェクト、コメント。Linear や Jira を使ったことがあれば、これまでの内容はまったく目新しくないはずです。
|
||||
|
||||
しかし Multica の決定的な特徴はまだ登場していません。**エージェントをワークスペースの一級メンバーとして扱うこと**です。次はまさにこの話に移ります。
|
||||
|
||||
## 次へ
|
||||
|
||||
- [エージェント](/agents) — 何であり、人とどう違うのか
|
||||
- [コメントでエージェントをメンションする](/mentioning-agents) — コメントで `@` を使ってエージェントを起動する
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 이슈 참조하기
|
||||
|
||||
다른 이슈를 링크하려면 댓글 mention 선택기에서 해당 이슈를 선택하세요. Multica는 이슈 링크를 명시적인 `[MUL-123](mention://issue/<uuid>)` mention 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
|
||||
다른 이슈를 링크하려면 `MUL-123`처럼 이슈 키를 입력하세요. Multica는 댓글에서 실제 존재하는 이슈 키를 해석하여 내부적으로 `mention://issue/<uuid>` 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
|
||||
|
||||
`MUL-123` 같은 bare 이슈 키를 입력하면 일반 텍스트로 유지됩니다. 따라서 `feature/MUL-123` 같은 댓글 안의 브랜치 이름과 경로도 다시 작성되지 않습니다.
|
||||
보통은 `[MUL-123](mention://issue/<uuid>)`을 직접 손으로 작성할 필요가 없습니다. 그 형식은 Multica가 키를 해석한 뒤에 사용하는 표준 내부 표현입니다.
|
||||
|
||||
<Callout type="info">
|
||||
Markdown 강조는 CommonMark 규칙을 따릅니다. 굵은 텍스트가 문장 부호나 닫는 따옴표로 끝나고 그 뒤에 한국어 조사가 바로 이어지면, 닫는 `**`가 인식되지 않을 수 있습니다.
|
||||
|
||||
@@ -39,9 +39,9 @@ Mentioning the same person multiple times in one comment still produces **only o
|
||||
|
||||
## Referencing issues
|
||||
|
||||
To link another issue, choose it from the comment mention picker. Multica stores issue links as an explicit `[MUL-123](mention://issue/<uuid>)` mention link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
|
||||
To link another issue, type its issue key, such as `MUL-123`. Multica resolves real issue keys in comments and stores them as an internal `mention://issue/<uuid>` link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
|
||||
|
||||
Typing a bare issue key, such as `MUL-123`, keeps it as plain text. This also keeps branch names and paths, such as `feature/MUL-123`, from being rewritten inside comments.
|
||||
You normally do not need to write `[MUL-123](mention://issue/<uuid>)` by hand. That format is the canonical internal representation after Multica has resolved the key.
|
||||
|
||||
<Callout type="info">
|
||||
Markdown emphasis follows CommonMark rules. When bold text ends with punctuation or a closing quote and is immediately followed by a Korean particle, the closing `**` may not be recognized.
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 引用 issue
|
||||
|
||||
要链接另一个 issue,请在评论的 mention 选择器里选择它。Multica 会把 issue 链接存成显式的 `[MUL-123](mention://issue/<uuid>)` mention 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
|
||||
要链接另一个 issue,直接输入它的 issue key,例如 `MUL-123`。Multica 会在评论中解析真实存在的 issue key,并把它存成内部的 `mention://issue/<uuid>` 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
|
||||
|
||||
直接输入裸 issue key,例如 `MUL-123`,会保持为普通文本。这样评论里的分支名和路径,例如 `feature/MUL-123`,也不会被改写。
|
||||
通常不需要手写 `[MUL-123](mention://issue/<uuid>)`。这是 Multica 解析 key 之后使用的内部规范格式。
|
||||
|
||||
<Callout type="info">
|
||||
Markdown 加粗遵循 CommonMark 规则。当加粗文本以标点或闭引号结尾,并且后面紧跟韩语助词时,结尾的 `**` 可能不会被识别。
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
---
|
||||
title: デーモンとランタイム
|
||||
description: エージェントは Multica のサーバーでは実行されません — あなた自身のマシンで実行されます。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
Multica では、[エージェント](/agents)は私たちのサーバーでは実行され**ません** — ローカルにインストールされた [AI コーディングツール](/providers)を呼び出す**デーモン**という小さなプログラムが駆動し、あなた自身のマシンで実行されます。Multica サーバーは調整役に徹します。[イシュー](/issues)を保存し、[タスク](/tasks)をキューに入れ、適切な**ランタイム**へ分配します(ランタイム = デーモン × AI コーディングツール 1 つ)。
|
||||
|
||||
この構造が Multica と Linear / Jira の最大の違いです。**あなたの API キー、ツールチェーン、コードディレクトリはすべてあなたのマシンに残り**、Multica サーバーはそのどれも見ることはありません。つまり「自分のエージェントが動かない」はほとんど常にローカルの問題です。デーモンが実行されていない、AI ツールがインストールされていない、キーが期限切れになっている、といったことです。まずローカルを確認してください。案内は[トラブルシューティング](/troubleshooting)を参照してください。
|
||||
|
||||
## デーモンを起動する
|
||||
|
||||
デーモンは Multica CLI の一部です。[Multica CLI](/cli) をインストールしたら、あなた自身のマシンで実行してください。
|
||||
|
||||
```bash
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
起動時にデーモンは 4 つのことを行います。
|
||||
|
||||
1. ログイン時に保存された認証情報を読み込みます
|
||||
2. `PATH` にインストールされた AI コーディングツールを検出します(内蔵 12 種: [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
3. 検出した各ツールに対するランタイムとともに、自身をサーバーに登録します
|
||||
4. **3 秒ごと**に取得すべきタスクがないかポーリングし、**15 秒ごとにハートビートを送信**し続けます
|
||||
|
||||
よく使うコマンド:
|
||||
|
||||
| コマンド | 用途 |
|
||||
|---|---|
|
||||
| `multica daemon start` | 起動(デフォルトはバックグラウンド。フォアグラウンドで実行するには `--foreground` を追加) |
|
||||
| `multica daemon stop` | 停止 |
|
||||
| `multica daemon restart` | 再起動 |
|
||||
| `multica daemon status` | ステータス表示 |
|
||||
| `multica daemon logs` | ログ表示(追従するには `-f` を追加) |
|
||||
|
||||
完全な CLI リファレンスは [CLI コマンド](/cli)を確認してください。
|
||||
|
||||
**デスクトップアプリにはデーモンが同梱されています。** [デスクトップアプリ](/desktop-app)を使う場合、`multica daemon start` を手動で実行する必要はありません。起動時にデーモンを自動的に立ち上げます。あなたのワークフローにどの方式が合うかは、[デスクトップアプリ](/desktop-app)ページを参照してください。
|
||||
|
||||
## 1 つのマシンに複数のランタイムができる理由
|
||||
|
||||
ランタイムはサーバーでもコンテナでもありません。「**デーモン × AI コーディングツール 1 つ**」の組み合わせです。たとえば、Claude Code と Codex の両方がインストールされた MacBook でデーモンを起動し、あなたが 2 つのワークスペースのメンバーだとします。すると Multica は 4 つのランタイムを登録します。
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
D["あなたのデーモン<br/>MacBook"]
|
||||
D --> R1["ランタイム<br/>ワークスペース A × Claude Code"]
|
||||
D --> R2["ランタイム<br/>ワークスペース A × Codex"]
|
||||
D --> R3["ランタイム<br/>ワークスペース B × Claude Code"]
|
||||
D --> R4["ランタイム<br/>ワークスペース B × Codex"]
|
||||
`} />
|
||||
|
||||
要点:
|
||||
|
||||
- **1 つのデーモンは複数のランタイムにマッピングされ得ます** — インストールされたツールと、あなたが所属するワークスペースの組み合わせごとに 1 つできます
|
||||
- **同じデーモン、ワークスペース、ツールは、ちょうど 1 つのランタイムを作ります** — デーモンを再起動しても重複レコードは生まれません
|
||||
- Multica UI の**ランタイム**ページがこれらの行を一覧表示します
|
||||
|
||||
## カスタムランタイムプロファイル
|
||||
|
||||
組み込み provider 検出は一般的なツールを対象にしていますが、Multica が対応するプロトコルファミリーで動作し、ワークスペース固有の起動コマンドが必要な AI CLI には **custom runtime profile** を定義できます。Runtimes UI か CLI で管理します。
|
||||
|
||||
```bash
|
||||
multica runtime profile list
|
||||
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
|
||||
multica runtime profile update <profile-id> --command-name agent
|
||||
multica runtime profile delete <profile-id>
|
||||
```
|
||||
|
||||
入力するコマンドは shell 文字列ではなく argv 形式です。Multica は実行ファイル名と固定引数を保存し、デーモンは `exec.Command(command_name, fixed_args...)` で直接起動します。通常の引数、引用符、バックスラッシュエスケープは使えますが、パイプ、リダイレクト、`&&`、`;`、バッククォート、`$VAR` / `$(...)` 展開は使えません。shell の動作が必要な場合は wrapper script を使ってください。
|
||||
現在、コマンドと引数の解析は Runtimes UI が担当します。CLI の profile コマンドは profile 行とローカルのパス上書きを管理します。
|
||||
|
||||
デスクトップアプリから起動したデーモンが、ターミナルでは動くコマンドを見つけられない場合は、そのマシンで絶対パスを固定できます。
|
||||
|
||||
```bash
|
||||
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
|
||||
multica runtime profile unset-path <profile-id>
|
||||
```
|
||||
|
||||
profile のコマンドや引数の変更は、デーモンが再登録された後に新しく取得するタスクへ適用されます。実行中のタスクは開始時の引数を使い続けます。混在デプロイでは、先に server をアップグレードしてから daemon を順次更新することを推奨します。`fixed_args` の入力は server 側の Runtimes UI が担い、`failed_profiles` 登録レポートも server が表示します。古いコンポーネントは未知のフィールドを明示的に失敗させず無視することがあるため、server を先に更新すると rollout を観測しやすくなります。
|
||||
|
||||
<Callout type="info">
|
||||
**クラウドランタイムが近日提供されます。** 現在は順番待ちリストの段階です。提供が始まれば、ローカルのデーモンを実行せずに Multica Cloud 上で直接エージェントタスクを実行できるようになります。[ダウンロードページ](https://multica.ai/download)でメールアドレスを登録すると通知を受け取れます。
|
||||
</Callout>
|
||||
|
||||
## ランタイムがオフラインと表示される時点
|
||||
|
||||
Multica はハートビートでランタイムがオンラインかどうかを判断します。3 つの重要な数値があります。
|
||||
|
||||
| イベント | しきい値 |
|
||||
|---|---|
|
||||
| デーモンのハートビート頻度 | **15 秒**ごと |
|
||||
| 欠落として表示 | **45 秒**間ハートビートなし(3 回欠落) |
|
||||
| 自動削除 | 関連するエージェントがない状態で **7 日**以上欠落 |
|
||||
|
||||
欠落は永続的ではありません。デーモンが再びハートビートを送った瞬間にオンラインに戻り、ランタイムレコードも保持されます。デーモンを再起動してもランタイムは失われません。
|
||||
|
||||
<Callout type="warning">
|
||||
**欠落したランタイムで実行中だったタスクは失敗として表示されます**(失敗理由 `runtime_offline`)。リトライ可能なソース(イシュー、チャット)については、Multica が自動的に再度キューに入れます。オートパイロットがトリガーしたタスクは自動的にはリトライされません。[タスク → どの失敗が自動リトライされるか](/tasks#which-failures-retry-automatically-which-dont)を参照してください。
|
||||
</Callout>
|
||||
|
||||
## いくつのタスクを並列に実行できるか
|
||||
|
||||
Multica は 2 つの層で同時実行数の制限を適用します。
|
||||
|
||||
- **デーモン層**: デフォルトで**同時タスク 20 個**(環境変数 `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` で調整可能)
|
||||
- **エージェント層**: デフォルトで**エージェントあたり同時タスク 6 個**(エージェントごとに設定)
|
||||
|
||||
2 つのうち厳しい方が適用されます。デーモンがすでにタスク 20 個を実行中なら、あるエージェントに余裕が残っていても新しいタスクは待機します。
|
||||
|
||||
タスクが `dispatched` に進めず `queued` で止まっている場合、通常はこの 2 つの制限のいずれかが飽和しています。
|
||||
|
||||
## デーモンのクラッシュ後、進行中だったタスクはどうなるか
|
||||
|
||||
デーモンがクラッシュしたり強制終了されたりすると、デーモンが取得していたタスクは `dispatched` または `running` 状態に残ります。次回の起動時、デーモンはサーバーに「これらのタスクはもう私のものではないので、失敗として表示してください」と伝えます。サーバーはそれを理由 `runtime_recovery` とともに `failed` に切り替えます。リトライ可能なソースについては、タスクが自動的に再度キューに入ります。
|
||||
|
||||
この手順がネットワークの問題で失敗しても、バックアップとして**30 秒ごと**にサーバー側のスキャンが回ります。45 秒以上ハートビートのないランタイムは欠落として表示され、その上のタスクも一緒に回収されます。
|
||||
|
||||
## 動かないエージェントのトラブルシューティング
|
||||
|
||||
「自分のエージェントが動かない」という問題に遭遇したら、まずこの 3 ステップのチェックリストを進めてください。
|
||||
|
||||
1. `multica daemon status` を実行し、デーモンが実行中でオンラインかを確認します
|
||||
2. `multica daemon logs -f` を実行し、エラーがないかを確認します
|
||||
3. Multica UI の**ランタイム**ページを開き、ランタイムが「オンライン」と表示されているかを確認します
|
||||
|
||||
より多くのシナリオは[トラブルシューティング](/troubleshooting)を参照してください。
|
||||
|
||||
## 次へ
|
||||
|
||||
- [タスク](/tasks) — デーモンがタスクを取得した後の全ライフサイクル
|
||||
- [プロバイダー対照表](/providers) — 12 種の AI コーディングツールの機能の違い
|
||||
@@ -58,29 +58,6 @@ graph TD
|
||||
- **같은 데몬, 워크스페이스, 도구는 정확히 하나의 런타임을 만듭니다.** 데몬을 재시작해도 중복 레코드가 생기지 않습니다
|
||||
- Multica UI의 **런타임** 페이지가 이 행들을 나열합니다
|
||||
|
||||
## 사용자 지정 런타임 프로필
|
||||
|
||||
기본 provider 감지는 일반적인 도구를 다룹니다. 팀에서 Multica가 지원하는 프로토콜 패밀리로 동작하지만 워크스페이스별 실행 명령이 필요한 AI CLI를 쓰는 경우에는 **custom runtime profile**을 정의할 수 있습니다. Runtimes UI 또는 CLI에서 관리합니다.
|
||||
|
||||
```bash
|
||||
multica runtime profile list
|
||||
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
|
||||
multica runtime profile update <profile-id> --command-name agent
|
||||
multica runtime profile delete <profile-id>
|
||||
```
|
||||
|
||||
명령은 shell 문자열이 아니라 argv 형식입니다. Multica는 실행 파일 이름과 고정 인수를 저장하고, 데몬은 `exec.Command(command_name, fixed_args...)`로 직접 실행합니다. 일반 인수, 따옴표, 백슬래시 이스케이프는 지원하지만 파이프, 리다이렉션, `&&`, `;`, 백틱, `$VAR` / `$(...)` 확장은 지원하지 않습니다. shell 동작이 필요하면 wrapper script를 사용하세요.
|
||||
현재 명령과 인수 파싱은 Runtimes UI가 담당합니다. CLI의 profile 명령은 profile 행과 로컬 경로 override를 관리합니다.
|
||||
|
||||
데스크톱 앱에서 시작한 데몬이 터미널에서는 동작하는 명령을 찾지 못한다면, 해당 기기에서 절대 경로를 고정할 수 있습니다.
|
||||
|
||||
```bash
|
||||
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
|
||||
multica runtime profile unset-path <profile-id>
|
||||
```
|
||||
|
||||
profile의 명령이나 인수 변경은 데몬이 다시 등록된 뒤 새로 가져오는 작업부터 적용됩니다. 이미 실행 중인 작업은 시작할 때의 인수를 유지합니다. 혼합 배포에서는 server를 먼저 업그레이드한 뒤 daemon을 순차적으로 업데이트하는 것을 권장합니다. `fixed_args` 입력은 server 쪽 Runtimes UI가 담당하고, `failed_profiles` 등록 보고도 server가 표시합니다. 오래된 구성요소는 알 수 없는 필드를 명확히 실패시키기보다 무시할 수 있으므로 server를 먼저 올리면 rollout을 더 잘 관찰할 수 있습니다.
|
||||
|
||||
<Callout type="info">
|
||||
**클라우드 런타임이 곧 제공됩니다.** 현재는 대기자 명단 단계입니다. 제공이 시작되면 로컬 데몬을 실행하지 않고도 Multica Cloud에서 직접 에이전트 작업을 실행할 수 있습니다. [다운로드 페이지](https://multica.ai/download)에서 이메일로 등록하면 알림을 받을 수 있습니다.
|
||||
</Callout>
|
||||
|
||||
@@ -58,44 +58,6 @@ Key points:
|
||||
- **The same daemon, workspace, and tool produces exactly one runtime** — restarting the daemon never creates duplicate records
|
||||
- The **Runtimes** page in the Multica UI lists these rows
|
||||
|
||||
## Custom runtime profiles
|
||||
|
||||
Built-in provider detection covers the common tools, but teams can also define
|
||||
**custom runtime profiles** for AI CLIs that speak one of Multica's supported
|
||||
protocol families and need a workspace-specific launch command. Profiles are
|
||||
managed from the Runtimes UI or the CLI:
|
||||
|
||||
```bash
|
||||
multica runtime profile list
|
||||
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
|
||||
multica runtime profile update <profile-id> --command-name agent
|
||||
multica runtime profile delete <profile-id>
|
||||
```
|
||||
|
||||
The command is argv-oriented, not a shell string. Multica stores an executable
|
||||
name plus fixed arguments, then the daemon launches it directly with
|
||||
`exec.Command(command_name, fixed_args...)`. Plain arguments, quotes, and
|
||||
backslash escaping are supported; pipes, redirects, `&&`, `;`, backticks, and
|
||||
`$VAR` / `$(...)` expansion are not. Use a wrapper script when the runtime needs
|
||||
shell behavior. Today the Runtimes UI owns command-and-argument parsing; the CLI
|
||||
profile commands manage the profile row and local path overrides.
|
||||
|
||||
If a desktop-launched daemon cannot find a command that works in your terminal,
|
||||
pin the absolute path on that machine:
|
||||
|
||||
```bash
|
||||
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
|
||||
multica runtime profile unset-path <profile-id>
|
||||
```
|
||||
|
||||
Profile command or argument edits apply to newly claimed tasks after the daemon
|
||||
re-registers. Running tasks keep the launch arguments they started with. For
|
||||
mixed deployments, upgrade the server before rolling out newer daemons: the
|
||||
server-side Runtimes UI stores `fixed_args`, and the server is what surfaces
|
||||
`failed_profiles` registration reports. Older components may ignore fields they
|
||||
do not understand instead of failing loudly, so treating the server upgrade as
|
||||
the first step keeps the rollout observable.
|
||||
|
||||
<Callout type="info">
|
||||
**Cloud runtimes are coming**, currently in a waitlist phase. Once available, you'll be able to execute agent tasks directly on Multica Cloud without running a local daemon. Sign up with your email on the [download page](https://multica.ai/download) to get notified.
|
||||
</Callout>
|
||||
|
||||
@@ -58,29 +58,6 @@ graph TD
|
||||
- **同一个守护进程在同一个工作区同一款工具上只会有一条运行时**——重启守护进程不会产生重复记录
|
||||
- Multica 界面的 **Runtimes** 页面列的就是这些行
|
||||
|
||||
## 自定义运行时配置
|
||||
|
||||
内置 provider 探测覆盖常见工具;如果团队有一款兼容 Multica 已支持协议族、但需要工作区级启动命令的 AI CLI,可以定义 **custom runtime profile**。你可以在 Runtimes UI 里管理,也可以用 CLI:
|
||||
|
||||
```bash
|
||||
multica runtime profile list
|
||||
multica runtime profile create --display-name "Composer" --protocol-family codex --command-name agent
|
||||
multica runtime profile update <profile-id> --command-name agent
|
||||
multica runtime profile delete <profile-id>
|
||||
```
|
||||
|
||||
这里填写的是 argv 风格命令,不是 shell 字符串。Multica 存的是可执行文件名和固定参数,守护进程会直接用 `exec.Command(command_name, fixed_args...)` 启动。支持普通参数、引号和反斜杠转义;不支持管道、重定向、`&&`、`;`、反引号、`$VAR` / `$(...)` 展开。需要 shell 行为时,用 wrapper script 包一层。
|
||||
目前命令和参数的解析入口在 Runtimes UI;CLI 的 profile 命令负责管理 profile 记录和本机路径覆盖。
|
||||
|
||||
如果桌面应用拉起的守护进程找不到你在终端里能运行的命令,可以在这台机器上固定绝对路径:
|
||||
|
||||
```bash
|
||||
multica runtime profile set-path <profile-id> --path /abs/path/to/agent
|
||||
multica runtime profile unset-path <profile-id>
|
||||
```
|
||||
|
||||
修改 profile 的命令或参数后,已开始的任务仍使用启动时的参数;守护进程重新注册后,新领取的任务才会使用新配置。混合版本部署时,建议先升级 server,再逐步升级 daemon:`fixed_args` 的录入在 server 侧 Runtimes UI,`failed_profiles` 注册报告也由 server 展示。旧组件可能会忽略自己不认识的字段,而不是明确报错;先升 server 能让 rollout 更可观察。
|
||||
|
||||
<Callout type="info">
|
||||
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
|
||||
</Callout>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
title: デスクトップアプリ
|
||||
description: Multica Desktop とは何か、Web アプリとどう違うのか、そしてどんなときに使う価値があるのかを解説します。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop は macOS、Windows、Linux 向けのネイティブデスクトップアプリです。設定された環境に対して、Web アプリと同じバックエンドに接続し、同じデータを表示します。Desktop はデフォルトで Multica Cloud を使用しますが、セルフホスト環境はローカルのランタイム設定ファイルで構成できます。Desktop はブラウザにはできないいくつかの機能も追加で提供します。**[ワークスペース](/workspaces)ごとの独立したタブグループ**、**[デーモン](/daemon-runtimes)の自動起動**、**ワンクリックアップグレード**です。
|
||||
|
||||
## Desktop か Web か — どちらを選ぶか
|
||||
|
||||
| | Web | Desktop |
|
||||
|---|---|---|
|
||||
| アクセス方法 | ブラウザで URL を開く | ネイティブアプリをインストール |
|
||||
| 複数タブ | ブラウザ自体のタブ(ワークスペースの区別なし) | **ワークスペースごとに独立したタブグループ 1 つ** |
|
||||
| デーモン | `multica daemon start` を自分で実行 | 起動時に**自動的に開始** |
|
||||
| アップグレード | 更新すると最新版になる | アプリがバックグラウンドで確認し、次回起動時にインストール |
|
||||
| ログイン後のデータ | 同一 | 同一 |
|
||||
|
||||
**Web を選ぶ**: 一度きりの利用、他人のマシンでの作業、何もインストールしたくないとき。
|
||||
**Desktop を選ぶ**: 毎日の利用、複数のワークスペースを同時に扱うとき、デーモンを手動で管理したくないとき。
|
||||
|
||||
## 複数タブ: ワークスペースを切り替えるとどうなるか
|
||||
|
||||
Desktop は**参加しているすべてのワークスペース**ごとに独立したタブグループを保持します。ワークスペースを切り替えると、現在のワークスペースのタブが 1 つの単位として非表示になり、以前のワークスペースのタブは離れたときのまま復元されます — VSCode のマルチワークスペースの挙動や、Slack でワークスペースを切り替えるのに似ています。
|
||||
|
||||
例: ワークスペース A でイシューのタブを 3 つ開いた状態でワークスペース B に切り替えます。A のタブ 3 つは消え、B には B で最後に開いていたものが表示されます。再び A に切り替えると、その 3 つのタブが以前の状態そのままに戻ってきます。**タブはワークスペース間で決して漏れ出しません。**
|
||||
|
||||
ログアウトすると**すべてのワークスペースのタブ状態が消去される**ため、複数のユーザーでマシンを共有していてもデータが漏れることはありません。
|
||||
|
||||
## Desktop の自動更新の仕組み
|
||||
|
||||
起動時に Desktop は GitHub Releases でより新しいバージョンがないかを確認します。新しいバージョンが見つかると、
|
||||
|
||||
1. バックグラウンドで新しいバージョンを静かにダウンロードします。
|
||||
2. 「準備完了 — 次回起動時にインストールされます」と通知します。
|
||||
3. 終了時(または次回の再起動時)に、アプリが閉じる前に更新をインストールします。
|
||||
4. 次回起動時に新しいバージョンが実行されます。
|
||||
|
||||
このプロセス全体は**作業中の内容を妨げません**。
|
||||
|
||||
<Callout type="warning">
|
||||
**Windows では ARM64 と x64 は別々の更新チャンネルです** — 間違ったアーキテクチャをインストールすると更新が検出されません。ダウンロードする際は、マシンに合った `.exe` を選んでください(ARM ビルドには `arm64` のサフィックスが付いています)。
|
||||
</Callout>
|
||||
|
||||
macOS ビルドは署名・公証されているため、初回起動時に「未確認の開発者」の警告は表示されません。Linux ビルドは `.AppImage` です — 自動更新は electron-updater に依存しており、一部のディストリビューションでは不安定になることがあります。**自動更新が動作しない場合は、新しいバージョンを手動でダウンロードして古いファイルを置き換えてください。**
|
||||
|
||||
## 単体の CLI とデーモンはまだ必要ですか?
|
||||
|
||||
**いいえ。** Desktop には同じ `multica` CLI バイナリが内蔵されており、起動時に独自のデーモンプロファイルを起動します(ターミナルから手動で実行しているデーモンとは隔離されます)。
|
||||
|
||||
すでに CLI をインストールして `multica daemon start` を手動で実行していても、Desktop はそのデーモンを乗っ取りません — 別のプロファイルで独自のデーモンを開始します。両者は**異なるランタイム**として登録され、UI では 2 つの独立したランタイムが表示されます。
|
||||
|
||||
ターミナルで CLI コマンドを実行したい場合、Desktop は特別な経路を提供しません — 別途インストールした CLI を使うか、アプリのリソースディレクトリ内 `resources/bin/multica` にあるバンドル済みのコピーを実行してください。
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Multica ダウンロードページ](https://multica.ai/download)から、使用するプラットフォームのインストーラーを入手してください。
|
||||
|
||||
| プラットフォーム | ファイル |
|
||||
|---|---|
|
||||
| macOS (Intel または Apple Silicon) | `.dmg` |
|
||||
| Windows x64 | `.exe`(標準) |
|
||||
| Windows ARM64 | `.exe`(`arm64` サフィックス付き) |
|
||||
| Linux | `.AppImage` |
|
||||
|
||||
初回起動時にはログインが必要です — Web アプリと同じメール + 認証コードのフローです。ログインすると、Desktop はワークスペース一覧を自動的に同期します。
|
||||
|
||||
<Callout type="info">
|
||||
**Desktop はデフォルトで Multica Cloud を使用しますが、ローカルの設定ファイルでセルフホスト環境を指すように設定できます。** アプリ内には依然として「セルフホストに接続」を選ぶピッカーはありません。Desktop はレンダラーが起動する前に `~/.multica/desktop.json` を読み込みます。ファイルがない場合は Cloud のデフォルト値を使用します。
|
||||
|
||||
最小構成のセルフホスト設定:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
`apiUrl` は必須で、`http` または `https` を使用する必要があります。Desktop は同一オリジン上で `wsUrl` を `/ws` として導出し(`https` なら `wss`、`http` なら `ws`)、API オリジンから `appUrl` を導出します。デプロイ環境が異なるオリジンを使用する場合は明示的に設定してください。
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
`desktop.json` は存在するが無効な場合、Desktop は安全側に倒して動作を停止し、Cloud に静かにフォールバックする代わりにブロック型の設定エラーを表示します。開発ビルドの場合、`electron-vite dev` 中は依然として `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL` が優先されます。ランタイムでの Desktop セルフホスト構成は [issue #1371](https://github.com/multica-ai/multica/issues/1371) で実装されました。
|
||||
</Callout>
|
||||
|
||||
## 次のステップ
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) — Desktop 向けの Cloud オンボーディングフロー
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — 自前のバックエンドを実行し、CLI または Desktop のランタイム設定で接続する
|
||||
- [デーモンとランタイム](/daemon-runtimes) — デーモンの仕組み(Desktop が代わりに起動してくれますが、動作は同じです)
|
||||
@@ -1,302 +0,0 @@
|
||||
---
|
||||
title: 規約
|
||||
description: コードのネーミング、i18n 翻訳用語集、中国語ボイスガイドの単一の信頼できる情報源。
|
||||
---
|
||||
|
||||
このページは、コードのネーミング、i18n 翻訳用語集、中国語ボイスガイドの単一の信頼できる情報源です。かつて `packages/views/locales/glossary.md` やあちこちに散らばったコメントにあった内容は、現在すべてここに集約されています。
|
||||
|
||||
Multica のコードを書く、翻訳を変更する、あるいは中国語の製品コピーを書く場合は、このページを参照してください。
|
||||
|
||||
---
|
||||
|
||||
## 1. コードのネーミング
|
||||
|
||||
### ルート
|
||||
|
||||
ワークスペース進入前のルート(ユーザーがワークスペースに入る前から存在するルート)は、必ず単一の単語または `/{noun}/{verb}` パターンを使用しなければなりません。
|
||||
|
||||
- ✅ `/login`, `/inbox`, `/workspaces/new`
|
||||
- ❌ `/new-workspace`, `/create-team`, `/accept-invite`
|
||||
|
||||
ルート直下のハイフンで連結した単語のまとまりは、ユーザーが自分で選んだワークスペースの slug と衝突し、際限のない予約 slug の監査を強いることになります。名詞(`workspaces`)を予約しておけば、`/workspaces/*` のサブツリー全体が自動的に保護されます。
|
||||
|
||||
### ワークスペーススコープのルート
|
||||
|
||||
常に `/{slug}/{section}` の下に置きます — `/{slug}/issues`、`/{slug}/agents`、`/{slug}/settings`。ワークスペースのルーティングロジックを絶対に重複させず、共有コードではフレームワーク固有の link API ではなく `useNavigation().push()` を使用してください。
|
||||
|
||||
### パッケージとモジュール
|
||||
|
||||
モノレポは厳格なパッケージ境界を強制します。
|
||||
|
||||
| パッケージ | 依存可能 | 依存禁止 |
|
||||
| --- | --- | --- |
|
||||
| `packages/core` | アプリ固有でないもののみ | `react-dom`, `localStorage`, `process.env`, `next/*`, UI ライブラリ |
|
||||
| `packages/ui` | なし | `@multica/core`, ビジネスロジック |
|
||||
| `packages/views` | `core/`, `ui/` | `next/*`, `react-router-dom`, stores |
|
||||
| `apps/web/platform/` | `next/*` | 他のアプリ |
|
||||
| `apps/desktop/.../platform/` | `react-router-dom`, electron | 他のアプリ |
|
||||
|
||||
両方のアプリに同じロジックが現れる場合は、必ず共有パッケージに抽出しなければなりません。「ささいな」重複という例外はありません。
|
||||
|
||||
### ファイルとコンポーネント
|
||||
|
||||
- ファイル: `kebab-case.tsx` / `kebab-case.ts`(例: `agent-row-actions.tsx`)
|
||||
- コンポーネント: `PascalCase`(例: `AgentRowActions`)
|
||||
- フック: `useCamelCase`(例: `useWorkspaceId`)
|
||||
- テスト: `<file>.test.ts(x)` として同じ場所に配置
|
||||
- ストア(Zustand): `<feature>-store.ts`、`use<Feature>Store` として export
|
||||
|
||||
### データベース(Go + sqlc)
|
||||
|
||||
- テーブル: `snake_case` の単数形(`user`, `workspace`, `agent_runtime`)
|
||||
- カラム: `snake_case`(`workspace_id`, `created_at`, `last_seen_at`)
|
||||
- 外部キー: `<table>_id`
|
||||
- ブール値: `is_<state>` または `<state>_at`(状態変更にはタイムスタンプ形式を推奨)
|
||||
- マイグレーションファイル: `NNN_descriptive_name.up.sql` + `.down.sql` — 常に双方向を提供する
|
||||
|
||||
### Go
|
||||
|
||||
- 標準の `gofmt` + `go vet`。例外なし。
|
||||
- Handler ファイルはドメインを反映する: `agent.go`, `auth.go`, `runtime.go`
|
||||
- テスト: `<file>_test.go` を同じ場所に配置
|
||||
- handler での UUID パースは、ルートの `CLAUDE.md` のルールに従ってください — 境界の入力には `parseUUIDOrBadRequest`、信頼できる往復には `parseUUID`(panic 版)を使い、error を確認せずに `util.ParseUUID` を直接使用しないでください。
|
||||
|
||||
### TypeScript
|
||||
|
||||
- ネットワーク上の API レスポンスは `snake_case` で、api client が境界で `camelCase` に変換します。TS コード内部では**常に camelCase**。
|
||||
- 型: `PascalCase`(`Issue`, `AgentRuntime`);`IPrefix` は禁止、`_t` サフィックスも禁止。
|
||||
- 列挙: string literal union を推奨し、ランタイムで反復処理が必要な場合にのみ `enum` を使用。
|
||||
- TanStack Query のキー: `<feature>/queries.ts` 内のファクトリ関数、例: `issueKeys.detail(id)`。
|
||||
|
||||
### イシューキー
|
||||
|
||||
すべてのイシューには `MUL-123` のような人が読めるキーがあります。ワークスペースの `issue_prefix`(大文字と数字、通常 3 文字、最大 10 文字)+ 連番です。ワークスペースの admin は Settings → General で接頭辞を変更できますが、変更すると既存のすべてのイシューが番号を振り直されるため、古い接頭辞が埋め込まれた外部参照(PR タイトル、ブランチ名、ドキュメントやチャット内のリンク)は解決されなくなります。
|
||||
|
||||
### コード内のコメント
|
||||
|
||||
英語のみです。リポジトリは Go と TypeScript の両方でこれを強制します。コード内に中国語のコメントを見つけたら、それはバグなので置き換えてください。
|
||||
|
||||
### コミットメッセージ
|
||||
|
||||
Conventional 形式: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`。意図ごとにまとめられたアトミックなコミット。
|
||||
|
||||
---
|
||||
|
||||
## 2. i18n 翻訳用語集
|
||||
|
||||
これは、すべての翻訳 PR が**必ず**守らなければならない用語集です。かつては `packages/views/locales/glossary.md` にありましたが、そのファイルは現在ここを指す stub です。
|
||||
|
||||
### 中核となる区別: エンティティ vs 概念
|
||||
|
||||
Multica の製品名詞は 2 つのカテゴリに分かれます。
|
||||
|
||||
- **エンティティ(Entity)** — URL、データベースの row、API の型を持ちます。中国語のテキストでは**小文字の英語**で表記し、視覚的に型名のように読めて「これは Multica のシステムエンティティだ」というシグナルを与えます。
|
||||
- **概念(Concept)** — 一般名詞であり、データベースのエンティティではありません。**完全に翻訳**し、中国語ユーザーが流れるテキストの中にギザギザの英語を見ないようにします。
|
||||
|
||||
このルールは `apps/docs/content/docs/*.zh.mdx` と整合しています — これらのドキュメントは事実上の中国語ボイス標準であり、20 ページ以上で実戦検証されています。
|
||||
|
||||
### エンティティ — 混合ルール(`issue` / `skill` / `task`)
|
||||
|
||||
`issue` / `skill` / `task` は Multica の中核エンティティです。スキーマのカラム、API のフィールド、製品 UI のラベルがすべて英語です。中国語のテキストでは**混合ルール**に従い、何を使うかは単語がどこに現れるかによって変わります。
|
||||
|
||||
| 文脈 | 表記 | 例 |
|
||||
| --- | --- | --- |
|
||||
| **UI 文字列、状態名、コード参照** | 小文字の英語 | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
|
||||
| **ドキュメントのタイトル / セクション見出し** | Title-case の英語 **または** 中国語の用語 | "Issue 与 project"、"Skills"、"执行任务" |
|
||||
| **長文ドキュメントの本文で、エンティティが文の主語になっている場合** | 中国語の用語、初出時に括弧内に英語 | "**执行任务**(task)是智能体每一次工作的单位" |
|
||||
| **API / DB フィールド** | 常に `task` / `issue` / `skill` | `task_id`, `issue_status`, `skill_uuid` |
|
||||
|
||||
中国語の用語の参考:
|
||||
|
||||
- `task` ↔ `执行任务`(文脈が明確になれば `任务` に短縮)
|
||||
- `issue` には定着した中国語訳がありません — 英語のまま;タイトルでは `Issue` のように大文字にできます
|
||||
- `skill` には定着した中国語訳がありません — 英語のまま;タイトルでは `Skills` のように大文字にできます
|
||||
|
||||
**`issue` / `skill` / `task` が `project` / `autopilot` のように中国語へ強制的に翻訳されない理由**:
|
||||
|
||||
- **`issue` / `task`**: 開発チームは英語で会話します。中国語の候補("任务" — あいまいすぎて "工作" とほぼ同義;"工单" — IT チケットのニュアンス;"议题" — GitHub 風だが製品の感覚に合わない)はいずれも `issue` よりも読みづらくなります。**ただし**、長文ドキュメントの本文で小文字の `task` を 50 回繰り返すとリズムが崩れるため、本文では `执行任务` を許容しつつ、UI 文字列と状態名は小文字の英語のままにします。
|
||||
- **`skill`**: 定着した中国語の用語がない Multica 固有の概念です。
|
||||
- **`project` → "项目"**: 定着した主流の中国語の単語です。Feishu / Tower / Teambition / PingCode / GitHub Projects — すべての中国語製品がこれを翻訳します。中国語の文脈で `project` をそのまま残す製品はありません。
|
||||
- **`autopilot` → "自动化"**: 中国語で "autopilot" は Tesla の "自动驾驶" を連想させ、この機能が行うこと(スケジュールに従ってタスクを実行する)と合いません。Notion も Feishu も "自动化" を使っており、それが業界の合意です。
|
||||
|
||||
### 翻訳しない — ブランドと頭字語
|
||||
|
||||
| カテゴリ | 用語 |
|
||||
| --- | --- |
|
||||
| ブランド | **Multica**, GitHub, Slack, Google, Anthropic, OpenAI, Claude, Codex, Cursor, Linear, Jira |
|
||||
| 頭字語 | API, CLI, URL, SDK, OAuth, JWT, SSO, WebSocket, HTTP, JSON, YAML, SQL |
|
||||
|
||||
### 完全に翻訳する — 概念
|
||||
|
||||
| English | Chinese |
|
||||
| --- | --- |
|
||||
| Workspace | **工作区** |
|
||||
| Agent | **智能体** |
|
||||
| Project | **项目** |
|
||||
| Autopilot | **自动化** |
|
||||
| Daemon | **守护进程** |
|
||||
| Runtime | **运行时** |
|
||||
| Inbox | **收件箱** |
|
||||
| Comment | **评论** |
|
||||
| Reply | **回复** |
|
||||
| Notifications | **通知** |
|
||||
| Member | **成员** |
|
||||
| Label | **标签** |
|
||||
| Settings | **设置** |
|
||||
| Onboarding | **上手引导** |
|
||||
|
||||
### 完全に翻訳する — 一般的な UI 用語
|
||||
|
||||
| English | Chinese |
|
||||
| --- | --- |
|
||||
| Invite / Invitation | 邀请 |
|
||||
| Search | 搜索 |
|
||||
| Email | 邮箱 (label) / 邮件 (action) |
|
||||
| Password | 密码 |
|
||||
| Sign in / Log in | 登录 |
|
||||
| Sign up | 注册 |
|
||||
| Sign out / Log out | 退出登录 |
|
||||
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Preview / Download / Upload | 预览 / 下载 / 上传 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
|
||||
| Active / Archived | 活跃 (or 启用) / 已归档 |
|
||||
| Status / Priority | 状态 / 优先级 |
|
||||
| Assignee / Reporter | 负责人 / 报告人 |
|
||||
| Description / Title | 描述 / 标题 |
|
||||
| Date / Time | 日期 / 时间 |
|
||||
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
|
||||
| Empty / Failed / Success | 空 / 失败 / 成功 |
|
||||
| Error / Warning | 错误 / 警告 |
|
||||
|
||||
### ロールと状態の列挙型(小文字の英語、翻訳しない)
|
||||
|
||||
これらはスキーマレベルの識別子です。中国語の文脈でも小文字の英語で表記します。
|
||||
|
||||
- ロール: `owner` / `admin` / `member`
|
||||
- イシューの状態: `backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
|
||||
|
||||
UI ではこれらの値を英語で表示します(必要に応じて `code-style` で囲む)。
|
||||
|
||||
- "你需要 owner 权限"
|
||||
- "已切换到 in_progress"
|
||||
|
||||
### 単語の組み合わせルール
|
||||
|
||||
英語の単語(エンティティ / ブランド / 頭字語)と周囲の中国語の間には、常に**単一の空白**を入れます。
|
||||
|
||||
- "Create new issue" → "新建 issue"
|
||||
- "Assign to agent" → "分配给智能体"
|
||||
- "Configure runtime" → "配置运行时"
|
||||
- "Stop daemon" → "停止守护进程"
|
||||
|
||||
### 複数形と数
|
||||
|
||||
i18next は `_one` / `_other` を使います。中国語には文法的な数がないため、`_other` のみを埋めます。
|
||||
|
||||
```json
|
||||
// en/issues.json
|
||||
{
|
||||
"issue_count_one": "{{count}} issue",
|
||||
"issue_count_other": "{{count}} issues"
|
||||
}
|
||||
|
||||
// zh-Hans/issues.json
|
||||
{
|
||||
"issue_count_other": "{{count}} 个 issue"
|
||||
}
|
||||
```
|
||||
|
||||
一般的な数の形式:
|
||||
|
||||
- `{{count}} issues` → `{{count}} 个 issue`
|
||||
- `{{count}} agents` → `{{count}} 个智能体`
|
||||
- `{{count}} workspaces` → `{{count}} 个工作区`
|
||||
- `{{count}} comments` → `{{count}} 条评论`
|
||||
- `{{count}} members` → `{{count}} 位成员`
|
||||
- `{{count}} skills` → `{{count}} 个 skill`
|
||||
|
||||
### 補間
|
||||
|
||||
`{{var}}` を使います。中国語の翻訳では、自然な文章の流れのために順序を並べ替えてもかまいません。
|
||||
|
||||
```json
|
||||
// en
|
||||
{ "welcome_message": "Welcome back, {{name}}!" }
|
||||
|
||||
// zh-Hans
|
||||
{ "welcome_message": "欢迎回来,{{name}}!" }
|
||||
```
|
||||
|
||||
### 翻訳キーのネーミング
|
||||
|
||||
3 階層のネスト: `feature.component.action`。
|
||||
|
||||
```json
|
||||
{
|
||||
"feature_or_component": {
|
||||
"subcomponent_or_section": {
|
||||
"action_or_label": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
例:
|
||||
|
||||
- `issues.toolbar.batch_update_success`
|
||||
- `issues.detail.comment_form.placeholder`
|
||||
- `inbox.empty.title`
|
||||
- `settings.preferences.language.title`
|
||||
|
||||
### Web 専用 / Desktop 専用のコピー
|
||||
|
||||
- 共有コピー: namespace JSON の最上位
|
||||
- Web 専用: `web` セクション
|
||||
- Desktop 専用: `desktop` セクション
|
||||
|
||||
正式な例は `auth.json` を参照してください(`web` セクションに `prefer_desktop` / `desktop_handoff.*` が含まれます)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 中国語のボイスとスタイル
|
||||
|
||||
### 句読点
|
||||
|
||||
- 中国語では全角の句読点を使用: `,。:;!?`
|
||||
- 引用符: 英語の原文に合わせて、まっすぐな二重引用符 `"..."` を使用。`「」` や丸い引用符は使わないでください。
|
||||
- 省略記号: 単一文字の `…` ではなく、3 つの点 `...`。英語の原文に合わせてください。
|
||||
- 中国語と英語の混在: 英語の単語の両側にそれぞれ単一の空白(単語の組み合わせルールを参照)。
|
||||
|
||||
### スタイルの原則
|
||||
|
||||
- **簡潔かつ直接的に。** 翻訳調を避ける: "对于 X 来说"、"作为 X"、"我们的"。
|
||||
- **エラーメッセージ**: 穏やかだが明確に。"无法保存修改" は "保存修改失败了!" よりも優れています。
|
||||
- **ボタン**: 動詞を先頭に、2〜4 文字。"取消"、"保存修改"、"立即同步"。
|
||||
- **ツールチップ**: 完結した短い文。"复制链接到剪贴板"。
|
||||
- **プレースホルダー**: 例の形式。"输入 issue 标题..."。
|
||||
|
||||
### 迷ったときに参照する場所
|
||||
|
||||
用語集が特定の用語を扱っていない場合は、次を参照してください。
|
||||
|
||||
1. `apps/docs/content/docs/*.zh.mdx` — 事実上の中国語ボイス標準、一貫した翻訳が 20 ページ以上
|
||||
2. `packages/views/locales/zh-Hans/auth.json` と `editor.json` — JSON 構造 + selector API パターン
|
||||
3. `packages/views/auth/login-page.tsx` — コンポーネントレベルの selector API 呼び出し箇所
|
||||
4. `packages/views/settings/components/preferences-tab.tsx` — 言語切り替えの参考
|
||||
|
||||
---
|
||||
|
||||
## このページを更新するとき
|
||||
|
||||
ここのルールを変更した場合は、次も併せて行ってください。
|
||||
|
||||
1. 関連する locale JSON / CLAUDE.md / ドキュメントページに適用する
|
||||
2. PR の説明に変更点を記録し、レビュアーが下流の一括対応を確認できるようにする
|
||||
|
||||
このページが契約です。他の何ものもこれを上書きできません。
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"title": "開発者",
|
||||
"pages": ["conventions"]
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
---
|
||||
title: 環境変数
|
||||
description: セルフホストの Multica サーバーを実行するための環境変数の完全な一覧です。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
セルフホストの Multica [サーバー](/self-host-quickstart)は、起動時に環境変数から設定を読み込みます — データベース、サインイン、メール、ストレージ、サインアップ許可リストはすべてここにあります。このページでは、すべての変数を用途別にグループ化しています。各セクションでは、**設定しないと何が起きるか**、そして**プロダクションで必ず設定すべきものはどれか**を明確に説明します。auth 関連の変数を実際にどう設定するかについては、[サインインとサインアップの設定](/auth-setup)を参照してください。
|
||||
|
||||
## コアサーバー変数
|
||||
|
||||
デプロイ前に必ず検討すべきコア変数です — 一部はサーバーを起動できるようにするデフォルト値を持っていますが、プロダクションでは必須項目を明示的に設定すべきです。
|
||||
|
||||
| 変数 | デフォルト | プロダクションで必須? |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **はい** |
|
||||
| `PORT` | `8080` | いいえ(ポートを変更する場合を除く) |
|
||||
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **はい**(デフォルトは安全ではありません) |
|
||||
| `APP_ENV` | 空 | **はい**(`production` である必要があります) |
|
||||
| `FRONTEND_ORIGIN` | 空 | **はい**(セルフホストは自身のドメインを設定する必要があります) |
|
||||
| `MULTICA_DEV_VERIFICATION_CODE` | 空 | いいえ(プロダクションでは必ず空のままにしてください) |
|
||||
|
||||
<Callout type="warning">
|
||||
**プロダクションでは `MULTICA_DEV_VERIFICATION_CODE` を空のままにしてください。** 固定のローカルテストコードはデフォルトで無効になっていますが、`MULTICA_DEV_VERIFICATION_CODE=888888` で有効にすると、`APP_ENV` が production 以外の間は、コードを要求できる誰もがその固定値でサインインできてしまいます。このショートカットは `APP_ENV=production` のときには無視されます。
|
||||
</Callout>
|
||||
|
||||
### データベース接続プール
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `DATABASE_MAX_CONNS` | `25` | pgxpool の最大接続数。デーモンは頻繁に(3 秒ごとに)ポーリングして接続を使用するため、規模の大きいデプロイではより高い値が必要になる場合があります |
|
||||
| `DATABASE_MIN_CONNS` | `5` | 最小アイドル接続数 |
|
||||
|
||||
**設定しない場合**、上記の値が使われます — 以前プロダクションでプール枯渇を引き起こした pgx 組み込みの 4/NumCPU デフォルトでは**ありません**。
|
||||
|
||||
## メール設定
|
||||
|
||||
Multica は 2 つの配信バックエンドをサポートします — クラウドデプロイ向けの [Resend](https://resend.com/) と、内部 / オンプレミスネットワーク向けの SMTP relay です。両方が設定されている場合は `SMTP_HOST` が `RESEND_API_KEY` より優先されます。
|
||||
|
||||
### Resend
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | 空 | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 送信元アドレス(Resend アカウントで検証済みのドメインである必要があり、SMTP を使用する場合も `From:` ヘッダーとして再利用されます) |
|
||||
|
||||
### SMTP relay
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | 空 | SMTP relay のホスト名。これを設定すると SMTP モードが有効になり、Resend を上書きします |
|
||||
| `SMTP_PORT` | `25` | SMTP ポート。STARTTLS サブミッションには `587` を、SMTPS(暗黙的 TLS、自動有効化)には `465` を使用します |
|
||||
| `SMTP_USERNAME` | 空 | SMTP ユーザー名。認証なしの relay の場合は空のままにしてください |
|
||||
| `SMTP_PASSWORD` | 空 | SMTP パスワード |
|
||||
| `SMTP_TLS` | `starttls` | TLS モード。`implicit`(別名 `smtps`、`ssl`)は接続時に即座に TLS ハンドシェイクを行います(SMTPS)。`465` ポートでは自動的に有効になります。未設定 / `starttls` の場合は接続後に STARTTLS でアップグレードします |
|
||||
| `SMTP_TLS_INSECURE` | `false` | TLS 証明書の検証をスキップするには `true` に設定(プライベート CA / 自己署名証明書のみ) |
|
||||
| `SMTP_EHLO_NAME` | マシンのホスト名 | relay に通知する EHLO/HELO 名。厳格な relay(例: Google Workspace `smtp-relay.gmail.com`)が公開 IP からのデフォルトの挨拶を拒否する場合は、実際の FQDN を設定してください — そうしないと relay が接続を切断し、後続のコマンドで不明瞭な `EOF` として表面化します |
|
||||
|
||||
サーバーが STARTTLS を通知すると自動的にアップグレードされます。dial タイムアウトは 10 秒で、SMTP セッション全体には 30 秒のデッドラインがあるため、ブラックホール化した relay が auth ハンドラーをハングさせることはできません。
|
||||
|
||||
**どちらも設定していない場合の動作**: サーバーはエラーを出しませんが、送信されるはずだったすべてのメール(検証コード、招待リンク)は**サーバーの stdout にのみ記録されます**。ローカル開発には便利です — サーバーログからコードをコピーして使ってください。**プロダクションでこれを設定し忘れると、静かなブラックホールが生まれ**、ユーザーはメールをまったく受け取れず、エラーも一切表面化しません。
|
||||
|
||||
## Google OAuth 設定
|
||||
|
||||
任意です。メール + 検証コードのみを使用する場合は設定しないままにし、サインインページに「Sign in with Google」を追加する場合は設定してください。
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `GOOGLE_CLIENT_ID` | 空 | Google Cloud OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | 空 | Google Cloud OAuth secret |
|
||||
| `GOOGLE_REDIRECT_URI` | `http://localhost:3000/auth/callback` | OAuth コールバック URL(セルフホスト: 自身のフロントエンドドメインに置き換えてください) |
|
||||
|
||||
**ランタイムで適用されます**: フロントエンドはこれらの設定をランタイムに `/api/config` 経由で読み込むため、**変更してもフロントエンドのリビルドや再デプロイは不要です** — サーバーを再起動すれば適用されます。
|
||||
|
||||
完全なセットアップ(Google Cloud Console の手順を含む)は[サインインとサインアップの設定](/auth-setup#google-oauth-configuration)にあります。
|
||||
|
||||
## ファイルストレージ設定
|
||||
|
||||
Multica はユーザーがアップロードした添付ファイル(コメント内の画像やファイル)を保存します。**S3 が推奨されます**。S3 が設定されていない場合はローカルディスクにフォールバックします。
|
||||
|
||||
### S3 / S3 互換ストレージ
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `S3_BUCKET` | 空 | **バケット名のみ**(例: `my-bucket`)。`.s3.<region>.amazonaws.com` サフィックスは含め**ないでください** — サーバーが `S3_BUCKET` + `S3_REGION` から公開ホストを構築します。これを設定すると S3 ストレージが有効になります |
|
||||
| `S3_REGION` | `us-west-2` | AWS リージョン。バケットの実際のリージョンと一致する必要があります — SDK 署名と公開 URL の構築の両方に使われます |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静的な認証情報。両方を設定しない場合は AWS SDK のデフォルト認証情報チェーン(IAM role / 環境認証情報)が使われます |
|
||||
| `AWS_ENDPOINT_URL` | 空 | カスタムの S3 互換エンドポイント(例: [MinIO](https://min.io/))。これを設定すると path-style URL に切り替わります |
|
||||
| `ATTACHMENT_DOWNLOAD_MODE` | `auto` | 添付ファイルのダウンロード方式: `auto`、`cloudfront`、`presign`、`proxy`。`auto` では CloudFront が完全に設定されている場合は優先し、内部/プライベート endpoint host は server proxy、公開 S3 互換 endpoint は対応時に presigned GET を使います |
|
||||
| `ATTACHMENT_DOWNLOAD_URL_TTL` | `30m` | CloudFront signed URL と S3 presigned download URL の有効期間。Go duration 形式を受け付けます |
|
||||
|
||||
**`S3_BUCKET` を設定しない場合**: サーバーは起動時に `"S3_BUCKET not set, cloud upload disabled"` をログに記録し、すべてのアップロードはローカルディスクにフォールバックします。
|
||||
|
||||
**保存されるオブジェクト URL** は次の優先順位で構築されます。
|
||||
|
||||
1. `CLOUDFRONT_DOMAIN` が設定されている場合は `https://<CLOUDFRONT_DOMAIN>/<key>`。
|
||||
2. `AWS_ENDPOINT_URL` が設定されている場合は `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>`(path-style)。
|
||||
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>`(virtual-hosted-style)。`S3_BUCKET` にドットが含まれる場合、AWS が発行するワイルドカード TLS 証明書がドットを含むバケットホストを検証できないため、サーバーは `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>`(path-style)にフォールバックします。
|
||||
|
||||
API の `download_url` は、CloudFront 署名が設定されていない場合 `GET /api/attachments/{id}/download` を使います。この endpoint は安全な場合 CloudFront/S3 presigned URL にリダイレクトし、`http://rustfs:9000` のようなプライベート/内部 endpoint では server がストリーミングします。Docker/VPC 内部のオブジェクトストアでは `ATTACHMENT_DOWNLOAD_MODE=proxy` を明示できます。
|
||||
|
||||
### ローカルディスク(S3 が設定されていない場合)
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `LOCAL_UPLOAD_DIR` | `./data/uploads` | ローカルストレージのディレクトリ |
|
||||
| `LOCAL_UPLOAD_BASE_URL` | 空(相対パスを返します) | 公開 base URL — 設定しないとフロントエンドが添付ファイルの完全な URL を解決できません |
|
||||
|
||||
### CloudFront(任意)
|
||||
|
||||
S3 の前段に CloudFront を置く場合、3 つの変数が適用されます: `CLOUDFRONT_DOMAIN`、`CLOUDFRONT_KEY_PAIR_ID`、`CLOUDFRONT_PRIVATE_KEY`(または Secrets Manager から読み込むには `CLOUDFRONT_PRIVATE_KEY_SECRET`)。CloudFront を使わない場合はスキップしてください — S3 設定とは競合しません。
|
||||
|
||||
### Cookie ドメイン
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `COOKIE_DOMAIN` | 空 | セッション cookie のスコープ |
|
||||
|
||||
- **空**: cookie は訪問した正確なホストでのみ有効です(単一ホストのデプロイに適切)
|
||||
- **`.example.com` に設定**: cookie がサブドメイン間で共有されます(そのため `app.example.com` と `api.example.com` がサインインセッションを共有します)
|
||||
- 警告: IP アドレスにはできません(ブラウザは無視します)
|
||||
|
||||
## 誰がサインアップできるかを制限する
|
||||
|
||||
3 つの許可リストの層が優先順位に従って組み合わされます。**いずれか 1 つの層でも空でない値に設定されると、一致しないメールは拒否されます** — `ALLOW_SIGNUP=true` でさえこれを上書きできません。
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `ALLOWED_EMAILS` | 空 | 明示的なメール許可リスト(カンマ区切り)。空でない場合、リストにあるメールのみがサインアップできます |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | 空 | ドメイン許可リスト(カンマ区切り)。空でない場合、リストにあるドメインのみがサインアップできます |
|
||||
| `ALLOW_SIGNUP` | `true` | サインアップのマスタースイッチ。サインアップを完全に無効にするには `false` に設定 |
|
||||
|
||||
**直感に反する部分**: `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` は「company.io または全員を許可」という意味では**なく**、**company.io のみを許可**という意味です。許可リストの層は AND セマンティクスです — 完全な決定木は[サインインとサインアップの設定 → サインアップ許可リスト](/auth-setup#restricting-who-can-sign-up)にあります。
|
||||
|
||||
**招待フロー自体はサインアップ許可リストをチェックしません** — ただし、招待された人は招待を承諾する前に依然として**サインイン**できる必要があります。すでに Multica アカウントを持っている場合(例: 別のワークスペースから)、許可リストの影響を受けずに直接承諾できます。**一度もサインアップしたことがない場合**、サインインの最初のステップ(検証コードの要求)は依然として許可リストのチェックを通過し、`ALLOW_SIGNUP=false` や `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` によって拒否されたメールは**サインアップを完了できず、したがって招待を承諾できません**。
|
||||
|
||||
## ワークスペース作成をロックダウンする
|
||||
|
||||
`ALLOW_SIGNUP=false` は新しいアカウントをブロックしますが、すでにサインイン済みのユーザーが `POST /api/workspaces` 経由で別のワークスペースを作成することは**ブロックしません**。すべてのイシュー、リポジトリ、エージェントがプラットフォーム管理者に見えなければならないセルフホストインスタンスでは、そのギャップを塞ぐために `DISABLE_WORKSPACE_CREATION=true` を設定してください。
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `DISABLE_WORKSPACE_CREATION` | `false` | `true` の場合、`POST /api/workspaces` へのすべての呼び出しが `403 workspace creation is disabled for this instance` を返します。Web UI は `/api/config` 経由ですべての「ワークスペース作成」要素を非表示にします。役割 / owner の例外はありません — このゲートはインスタンス単位で全体に適用されます |
|
||||
|
||||
推奨されるブートストラップ手順:
|
||||
|
||||
1. `DISABLE_WORKSPACE_CREATION` を設定しないまま(デフォルト)インスタンスを起動します。
|
||||
2. 管理者としてサインインし、共有ワークスペースを作成します。
|
||||
3. `DISABLE_WORKSPACE_CREATION=true` を設定してバックエンドを再起動します。この時点から、ユーザーは招待によってのみ参加できます。
|
||||
|
||||
招待されたユーザーが最初の検証コードでサインアップを完了できるよう `ALLOW_SIGNUP=true` を維持したい場合は、`DISABLE_WORKSPACE_CREATION=true` を `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS` と組み合わせて、どのアドレスがサインアップできるかの範囲を指定してください。`ALLOW_SIGNUP=false` を設定すると、保留中の招待対象者がアカウントを作成すること自体も追加でブロックされます — すべてのメンバーがすでに Multica アカウントを持っているインスタンスでのみ有用です。
|
||||
|
||||
## レート制限(任意の Redis)
|
||||
|
||||
公開 auth エンドポイント — `/auth/send-code`、`/auth/verify-code`、`/auth/google` — の前段には、IP ごとの固定ウィンドウのレート制限があります。リミッターは Redis によって支えられています。`REDIS_URL` を設定しない場合、ミドルウェアは **no-op**(fail-open)になり、バックエンドは起動時に `rate limiting disabled: REDIS_URL not configured` をログに記録します。
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `REDIS_URL` | 空 | Redis 接続 URL(例: `redis://localhost:6379/0`)。設定しないと auth エンドポイントのレート制限が無効になります。同じ Redis はリアルタイムハブの fan-out、PAT キャッシュ、デーモントークンキャッシュでも使われます — 設定しない場合はすべてインメモリ / 直接 DB モードにフォールバックします |
|
||||
| `RATE_LIMIT_AUTH` | `5` | `/auth/send-code` および `/auth/google` に対する IP あたり毎分の最大リクエスト数 |
|
||||
| `RATE_LIMIT_AUTH_VERIFY` | `20` | `/auth/verify-code` に対する IP あたり毎分の最大リクエスト数 |
|
||||
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | リミッターがその `X-Forwarded-For` ヘッダーを信頼することを許可する、カンマ区切りの CIDR。空(デフォルト)は **XFF を決して信頼しない**ことを意味します — リミッターは直接接続の `RemoteAddr` のみを使用します |
|
||||
|
||||
リクエストが制限を超えると、サーバーは `429 Too Many Requests`、`Retry-After: 60`、そして本文 `{"error":"too many requests"}` で応答します。
|
||||
|
||||
<Callout type="warning">
|
||||
**リバースプロキシの背後では `RATE_LIMIT_TRUSTED_PROXIES` を必ず設定する必要があります。** そうしないと、バックエンドの観点ではすべての実際のユーザーがプロキシの IP を共有することになり、デプロイ全体が 1 つのバケットに入り、`/auth/send-code` がサイト全体で毎分 5 リクエストになってしまいます。一般的な値: 同一ホストの Caddy / Nginx には `127.0.0.1/32,::1/128`、Cloudflare / ALB / CloudFront には該当 CDN が公開している IP 範囲。`RemoteAddr` がこれらの CIDR のいずれかに含まれる IP のみが、`X-Forwarded-For` を使ってクライアントを識別できます。
|
||||
</Callout>
|
||||
|
||||
この独立した `RATE_LIMIT_TRUSTED_PROXIES` は、オートパイロット webhook リミッター(`/api/webhooks/autopilots/{token}`)を制御する `MULTICA_TRUSTED_PROXIES` とは**異なります**。各リミッターは自身のリストをパースするため、プロキシの背後にあるデプロイは両方を設定すべきです。
|
||||
|
||||
## デーモンのチューニングパラメータ
|
||||
|
||||
デーモンはユーザーのローカルマシン上で実行され、その設定もローカル環境変数から読み込まれます。一般的なものは次のとおりです。
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | サーバーアドレス(セルフホスト: 自身のドメインに置き換えてください) |
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | ハートビート間隔 |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | タスクのポーリング間隔 |
|
||||
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 最大同時タスク数 |
|
||||
| `MULTICA_<PROVIDER>_PATH` | CLI 名に一致 | 各 AI コーディングツールの実行ファイルへのパス(例: `MULTICA_CLAUDE_PATH`) |
|
||||
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI コーディングツールのデフォルトモデル |
|
||||
|
||||
各パラメータがデーモンの動作にどう影響するかの完全な説明は、[デーモンとランタイム](/daemon-runtimes)を参照してください。
|
||||
|
||||
## フロントエンドのアクセス制御
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `FRONTEND_ORIGIN` | 空 | フロントエンドアドレス。招待メールのリンク、CORS 許可リスト、cookie ドメインはすべてこの値から派生します。設定しない場合、招待メールのリンクはホスト型ドメイン `https://app.multica.ai` にフォールバックします — セルフホストはこれを明示的に設定する必要があります |
|
||||
| `CORS_ALLOWED_ORIGINS` | 空 | 追加で許可する CORS origin(カンマ区切り) |
|
||||
| `ALLOWED_ORIGINS` | 空 | WebSocket 専用の origin 許可リスト(カンマ区切り)。設定しない場合、フォールバック順序は `CORS_ALLOWED_ORIGINS` → `FRONTEND_ORIGIN` → `localhost:3000/5173/5174` です |
|
||||
|
||||
<Callout type="warning">
|
||||
**`FRONTEND_ORIGIN` を設定しないと 2 つの静かな失敗が発生します**: (1) 招待メールのリンクが `https://app.multica.ai`(ホスト型ドメイン)を指し、クリックしてもユーザーがセルフホストインスタンスに戻ってこない。(2) WebSocket の Origin チェックが `localhost:3000 / 5173 / 5174` にフォールバックするため、プロダクションデプロイのすべての WebSocket 接続が拒否され、フロントエンドが「リアルタイム更新を受け取れない」ように見える。
|
||||
</Callout>
|
||||
|
||||
## GitHub 連携
|
||||
|
||||
[GitHub PR ↔ イシュー連携](/github-integration)には 2 つの必須変数が必要です。設定で Connect GitHub を有効にし、受信 webhook を受け付けるには両方を設定してください。さらに 2 つの任意変数を設定すると、インストール時点で連携先のアカウント名を取得できます。
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 空 | GitHub App の slug(`https://github.com/apps/<slug>` の末尾部分)。設定 → GitHub のインストールボタン URL を構成します |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 空 | GitHub App に設定した Webhook secret。すべての `pull_request` / `installation` delivery の HMAC-SHA256 検証に使われ、setup コールバックの state token の HMAC キーとしても使われます |
|
||||
| `GITHUB_APP_ID` | 空 | 任意。App 設定ページに表示される数値の App ID。`GITHUB_APP_PRIVATE_KEY` と併せて設定すると、setup コールバックがインストール時点で GitHub から連携先のアカウント名を取得できます |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | 空 | 任意。App の RSA 秘密鍵の完全な PEM ブロック(`-----BEGIN/END-----` 行を含み、改行を保ったまま)。GitHub の App 認証 REST 呼び出しに必要な短命 JWT の発行に使われます |
|
||||
|
||||
**いずれかの必須変数が設定されていない場合の動作:**
|
||||
|
||||
- 設定 → GitHub の `Connect GitHub` が**無効**になり、admin に「not configured」というヒントを表示します。
|
||||
- `/api/webhooks/github` エンドポイントは **`503 github webhooks not configured`** を返します — Multica はすべての署名を有効として扱うのではなく、secret なしではイベント処理を拒否します。
|
||||
|
||||
**任意の `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` が設定されていない場合の動作:**
|
||||
|
||||
- インストール直後、接続カードには一時的に `Connected to unknown` と表示されます。GitHub から `installation.created` webhook が届くと(通常は数秒以内)、Multica は行を実際の組織名/ユーザー名に更新し、リアルタイムブロードキャストを発行するため、開いている Settings → GitHub タブは手動更新なしで反映されます。
|
||||
|
||||
**注:** `GITHUB_WEBHOOK_SECRET` はインストールフローの state token の署名キーとして再利用されるため、運用者は secret を 1 つだけ管理すればよいです。これは GitHub App の *Client* secret では**ありません** — Client secret は OAuth 関連であり、この連携では使われません。完全な手順は [GitHub 連携 → セルフホストのセットアップ](/github-integration#self-host-setup)を参照してください。
|
||||
|
||||
## 使用量分析
|
||||
|
||||
デフォルトでは、サーバーは Multica の公式 PostHog インスタンスにレポートします。オプトアウトするには `ANALYTICS_DISABLED=true` を設定してください。
|
||||
|
||||
| 変数 | デフォルト | 説明 |
|
||||
|---|---|---|
|
||||
| `ANALYTICS_DISABLED` | `false` | バックエンド分析を完全に無効にするには `true` に設定 |
|
||||
| `POSTHOG_API_KEY` | 組み込みのデフォルトキー | 自身の PostHog インスタンスを指す場合に設定 |
|
||||
| `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog をセルフホストする場合は自身のホストに変更 |
|
||||
|
||||
## 次へ
|
||||
|
||||
- [サインインとサインアップの設定](/auth-setup) — 上記の auth 関連変数を実際にどう設定するか、そして落とし穴がどこにあるか
|
||||
- [GitHub 連携](/github-integration) — `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET` を支える GitHub App をどうセットアップするか
|
||||
- [トラブルシューティング](/troubleshooting) — よくある設定ミスの症状と対処法
|
||||
- [デーモンとランタイム](/daemon-runtimes) — `MULTICA_DAEMON_*` パラメータが実際に何をするか
|
||||
@@ -49,12 +49,10 @@ Multica는 두 가지 전송 백엔드를 지원합니다 — 클라우드 배
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | 비어 있음 | SMTP relay 호스트명. 이를 설정하면 SMTP 모드가 활성화되고 Resend를 덮어씁니다 |
|
||||
| `SMTP_PORT` | `25` | SMTP 포트. STARTTLS 제출에는 `587`을, SMTPS(암묵적 TLS, 자동 활성화)에는 `465`를 사용하세요 |
|
||||
| `SMTP_PORT` | `25` | SMTP 포트. STARTTLS 제출에는 `587`을 사용하세요; **포트 465(SMTPS / 암묵적 TLS)는 지원되지 않습니다** |
|
||||
| `SMTP_USERNAME` | 비어 있음 | SMTP 사용자명. 인증 없는 relay의 경우 비워 두세요 |
|
||||
| `SMTP_PASSWORD` | 비어 있음 | SMTP 비밀번호 |
|
||||
| `SMTP_TLS` | `starttls` | TLS 모드. `implicit`(별칭 `smtps`, `ssl`)은 연결 시 즉시 TLS 핸드셰이크를 수행합니다(SMTPS). `465` 포트에서는 자동으로 활성화됩니다. 미설정 / `starttls`는 연결 후 STARTTLS로 업그레이드합니다 |
|
||||
| `SMTP_TLS_INSECURE` | `false` | TLS 인증서 검증을 건너뛰려면 `true`로 설정 (사설 CA / 자체 서명 인증서만 해당) |
|
||||
| `SMTP_EHLO_NAME` | 머신 호스트명 | relay에 알리는 EHLO/HELO 이름. 엄격한 relay(예: Google Workspace `smtp-relay.gmail.com`)가 공개 IP에서 보내는 기본 greeting을 거부하는 경우 실제 FQDN을 설정하세요 — 그렇지 않으면 relay가 연결을 끊고, 이는 이후 명령에서 불투명한 `EOF`로 나타납니다 |
|
||||
|
||||
서버가 STARTTLS를 알리면 자동으로 업그레이드됩니다. dial 타임아웃은 10초이고 전체 SMTP 세션에는 30초 데드라인이 있어, 블랙홀이 된 relay가 auth 핸들러를 멈추게 할 수 없습니다.
|
||||
|
||||
@@ -86,19 +84,15 @@ Multica는 사용자가 업로드한 첨부 파일(댓글의 이미지와 파일
|
||||
| `S3_REGION` | `us-west-2` | AWS 리전. 버킷의 실제 리전과 일치해야 합니다 — SDK 서명과 공개 URL 구성 모두에 사용됩니다 |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 비어 있음 | 정적 자격 증명. 둘 다 설정하지 않으면 AWS SDK 기본 자격 증명 체인(IAM role / 환경 자격 증명)이 사용됩니다 |
|
||||
| `AWS_ENDPOINT_URL` | 비어 있음 | 사용자 정의 S3 호환 엔드포인트 (예: [MinIO](https://min.io/)). 이를 설정하면 path-style URL로 전환됩니다 |
|
||||
| `ATTACHMENT_DOWNLOAD_MODE` | `auto` | 첨부 파일 다운로드 방식: `auto`, `cloudfront`, `presign`, `proxy`. `auto`에서는 CloudFront가 완전히 설정되어 있으면 우선 사용하고, 내부/프라이빗 endpoint host는 server proxy를, 공개 S3 호환 endpoint는 지원되는 경우 presigned GET을 사용합니다 |
|
||||
| `ATTACHMENT_DOWNLOAD_URL_TTL` | `30m` | CloudFront signed URL 및 S3 presigned download URL의 유효 기간. Go duration 형식을 받습니다 |
|
||||
|
||||
**`S3_BUCKET`을 설정하지 않으면**: 서버는 시작 시 `"S3_BUCKET not set, cloud upload disabled"`를 로깅하고, 모든 업로드는 로컬 디스크로 폴백합니다.
|
||||
|
||||
**저장된 객체 URL**은 다음 우선순위 순서로 구성됩니다:
|
||||
**공개 URL**은 다음 우선순위 순서로 구성됩니다:
|
||||
|
||||
1. `CLOUDFRONT_DOMAIN`이 설정된 경우 `https://<CLOUDFRONT_DOMAIN>/<key>`.
|
||||
2. `AWS_ENDPOINT_URL`이 설정된 경우 `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style).
|
||||
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). `S3_BUCKET`에 점이 포함된 경우, AWS가 발급한 와일드카드 TLS 인증서가 점이 포함된 버킷 호스트를 검증하지 못하므로 서버는 `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style)로 폴백합니다.
|
||||
|
||||
API `download_url` 값은 CloudFront 서명이 설정되지 않은 경우 `GET /api/attachments/{id}/download`를 사용합니다. 이 endpoint는 안전한 경우 CloudFront/S3 presigned URL로 리디렉션하고, `http://rustfs:9000` 같은 프라이빗/내부 endpoint에서는 server가 스트리밍합니다. Docker/VPC 내부 객체 저장소에서는 `ATTACHMENT_DOWNLOAD_MODE=proxy`를 명시할 수 있습니다.
|
||||
|
||||
### 로컬 디스크 (S3가 설정되지 않은 경우)
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
@@ -198,24 +192,18 @@ S3 앞에 CloudFront를 두는 경우 세 가지 변수가 적용됩니다: `CLO
|
||||
|
||||
## GitHub 연동
|
||||
|
||||
[GitHub PR ↔ 이슈 연동](/github-integration)에는 두 개의 필수 변수가 필요합니다. 설정에서 Connect GitHub를 활성화하고 들어오는 webhook을 수락하려면 둘 다 설정하세요. 추가로 두 개의 선택 변수를 설정하면 설치 시점에 연결된 계정 이름을 즉시 가져올 수 있습니다.
|
||||
[GitHub PR ↔ 이슈 연동](/github-integration)에는 두 개의 변수가 필요합니다. 설정에서 Connect GitHub를 활성화하고 들어오는 webhook을 수락하려면 둘 다 설정하세요.
|
||||
|
||||
| 변수 | 기본값 | 설명 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 비어 있음 | GitHub App의 slug (`https://github.com/apps/<slug>`의 끝부분). 설정 → GitHub 설치 버튼 URL을 구성합니다 |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 비어 있음 | GitHub App에 설정한 Webhook secret. 모든 `pull_request` / `installation` delivery의 HMAC-SHA256 검증에 사용되며, setup 콜백 state token의 HMAC 키로도 사용됩니다 |
|
||||
| `GITHUB_APP_ID` | 비어 있음 | 선택. App 설정 페이지에 표시되는 숫자 App ID. `GITHUB_APP_PRIVATE_KEY`와 함께 설정하면 setup 콜백이 설치 시점에 GitHub에서 연결된 계정 이름을 가져올 수 있습니다 |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | 비어 있음 | 선택. App RSA 비공개 키의 전체 PEM 블록 (`-----BEGIN/END-----` 줄 포함, 줄바꿈 유지). GitHub의 App 인증 REST 호출에 필요한 단명 JWT를 발급하는 데 사용됩니다 |
|
||||
|
||||
**필수 변수 중 하나라도 설정하지 않았을 때의 동작:**
|
||||
**둘 중 하나라도 설정하지 않았을 때의 동작:**
|
||||
|
||||
- 설정 → GitHub의 `Connect GitHub`가 **비활성화**되고 admin에게 "not configured" 힌트를 표시합니다.
|
||||
- `/api/webhooks/github` 엔드포인트는 **`503 github webhooks not configured`**를 반환합니다 — Multica는 모든 서명을 유효한 것으로 취급하기보다, secret 없이는 이벤트 처리를 거부합니다.
|
||||
|
||||
**선택 `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY`가 설정되지 않았을 때의 동작:**
|
||||
|
||||
- 설치 직후 연결 카드에 잠시 `Connected to unknown`이 표시됩니다. GitHub의 `installation.created` 웹훅이 도착하면(보통 몇 초 이내) Multica가 행을 실제 조직/사용자 이름으로 갱신하고 실시간 브로드캐스트를 보내, 열려 있는 Settings → GitHub 탭이 수동 새로고침 없이 업데이트됩니다.
|
||||
|
||||
**참고:** `GITHUB_WEBHOOK_SECRET`은 설치 흐름 state token의 서명 키로 재사용되므로, 운영자는 secret 하나만 관리하면 됩니다. 이것은 GitHub App의 *Client* secret이 **아닙니다** — Client secret은 OAuth 관련이며 이 연동에서는 사용되지 않습니다. 전체 안내는 [GitHub 연동 → 자체 호스팅 설정](/github-integration#self-host-setup)을 참고하세요.
|
||||
|
||||
## 사용량 분석
|
||||
|
||||
@@ -54,7 +54,6 @@ Multica supports two delivery backends — [Resend](https://resend.com/) for clo
|
||||
| `SMTP_PASSWORD` | empty | SMTP password |
|
||||
| `SMTP_TLS` | `starttls` | TLS mode. `implicit` (aliases `smtps`, `ssl`) forces an immediate TLS handshake on connect (SMTPS); port `465` auto-enables it. Unset / `starttls` upgrades via STARTTLS after connect |
|
||||
| `SMTP_TLS_INSECURE` | `false` | Set `true` to skip TLS certificate verification (private CA / self-signed only) |
|
||||
| `SMTP_EHLO_NAME` | machine hostname | EHLO/HELO name announced to the relay. Set a real FQDN when a strict relay (e.g. Google Workspace `smtp-relay.gmail.com`) rejects the default greeting from a public IP — otherwise the relay drops the connection and it surfaces as an opaque `EOF` on a later command |
|
||||
|
||||
STARTTLS is upgraded automatically when the server advertises it. The dial timeout is 10s and the whole SMTP session has a 30s deadline, so a black-holed relay can't hang the auth handler.
|
||||
|
||||
@@ -86,19 +85,15 @@ Multica stores user-uploaded attachments (images and files in comments). **S3 is
|
||||
| `S3_REGION` | `us-west-2` | AWS region. Must match the bucket's actual region — it is used both for SDK signing and for building the public URL |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | empty | Static credentials. When both are unset, the AWS SDK default credential chain is used (IAM role / environment credentials) |
|
||||
| `AWS_ENDPOINT_URL` | empty | Custom S3-compatible endpoint (for example [MinIO](https://min.io/)). Setting this switches to path-style URLs |
|
||||
| `ATTACHMENT_DOWNLOAD_MODE` | `auto` | Attachment download path: `auto`, `cloudfront`, `presign`, or `proxy`. In `auto`, CloudFront is preferred when fully configured; internal/private endpoint hosts use the server proxy; public S3-compatible endpoints use presigned GET URLs when supported |
|
||||
| `ATTACHMENT_DOWNLOAD_URL_TTL` | `30m` | TTL for CloudFront signed URLs and S3 presigned download URLs. Accepts Go duration strings |
|
||||
|
||||
**When `S3_BUCKET` is unset**: the server logs `"S3_BUCKET not set, cloud upload disabled"` at startup, and all uploads fall back to local disk.
|
||||
|
||||
**Stored object URLs** are constructed in this order of priority:
|
||||
**Public URLs** are constructed in this order of priority:
|
||||
|
||||
1. `https://<CLOUDFRONT_DOMAIN>/<key>` if `CLOUDFRONT_DOMAIN` is set.
|
||||
2. `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style) if `AWS_ENDPOINT_URL` is set.
|
||||
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). When `S3_BUCKET` contains dots, the server falls back to `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style) because the AWS-issued wildcard TLS certificate does not validate dotted bucket hosts.
|
||||
|
||||
API `download_url` values use `GET /api/attachments/{id}/download` unless CloudFront signing is configured. The endpoint redirects to CloudFront/S3 presigned URLs when safe, or streams through the server for private/internal endpoints such as `http://rustfs:9000`. For Docker/VPC-only object stores, set `ATTACHMENT_DOWNLOAD_MODE=proxy` if auto detection is not conservative enough for your network.
|
||||
|
||||
### Local disk (when S3 is not configured)
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -200,24 +195,18 @@ For a full explanation of how each parameter affects daemon behavior, see [Daemo
|
||||
|
||||
## GitHub integration
|
||||
|
||||
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks. Two additional variables are optional but populate the connected account name on install.
|
||||
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → GitHub install button URL |
|
||||
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
|
||||
| `GITHUB_APP_ID` | empty | Optional. Numeric App ID from the App's settings page. Combined with `GITHUB_APP_PRIVATE_KEY`, lets the setup callback fetch the connected account name from GitHub immediately on install |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | empty | Optional. Full PEM block of the App's RSA private key (including `-----BEGIN/END-----` lines, newlines preserved). Used to mint the short-lived JWT GitHub requires for App-authenticated REST calls |
|
||||
|
||||
**Behavior when either of the required variables is unset:**
|
||||
**Behavior when either is unset:**
|
||||
|
||||
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
|
||||
|
||||
**Behavior when the optional `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` are unset:**
|
||||
|
||||
- The connection card briefly shows `Connected to unknown` after install. Multica refreshes the row to the real org/user name as soon as GitHub delivers the `installation.created` webhook (typically within a few seconds), and broadcasts a realtime update so any open Settings → GitHub tab reflects the change without a manual refresh.
|
||||
|
||||
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
|
||||
|
||||
## Usage analytics
|
||||
|
||||
@@ -54,7 +54,6 @@ Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合
|
||||
| `SMTP_PASSWORD` | 空 | SMTP 密码 |
|
||||
| `SMTP_TLS` | `starttls` | TLS 模式。`implicit`(别名 `smtps`、`ssl`)在连接时立即进行 TLS 握手(SMTPS);`465` 端口会自动启用。未设置 / `starttls` 则在连接后通过 STARTTLS 升级 |
|
||||
| `SMTP_TLS_INSECURE` | `false` | 设为 `true` 跳过 TLS 证书校验(仅限私有 CA / 自签证书)|
|
||||
| `SMTP_EHLO_NAME` | 机器主机名 | 向 relay 通告的 EHLO/HELO 名称。当严格的 relay(例如 Google Workspace `smtp-relay.gmail.com`)拒绝来自公网 IP 的默认问候时,填一个真实的 FQDN——否则 relay 会直接断开连接,并在后续某条命令上表现为一个不知所云的 `EOF` |
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。dial 超时 10s,整个 SMTP 会话有 30s deadline,避免 relay 黑洞把 auth handler 挂死。
|
||||
|
||||
@@ -86,19 +85,15 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
| `S3_REGION` | `us-west-2` | AWS 区域。必须和 bucket 所在区域一致——SDK 签名和公开 URL 都用它 |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静态凭证。全未设时用 AWS SDK 默认凭证链(IAM role / 环境凭证)|
|
||||
| `AWS_ENDPOINT_URL` | 空 | 自定义 S3 兼容端点(例如 [MinIO](https://min.io/))。设了会切到 path-style URL |
|
||||
| `ATTACHMENT_DOWNLOAD_MODE` | `auto` | 附件下载路径:`auto`、`cloudfront`、`presign` 或 `proxy`。`auto` 下 CloudFront 配完整时优先 CloudFront;内网/私有 endpoint host 走 server proxy;公网 S3 兼容 endpoint 在支持时走 presigned GET |
|
||||
| `ATTACHMENT_DOWNLOAD_URL_TTL` | `30m` | CloudFront signed URL 和 S3 presigned download URL 的有效期。使用 Go duration 格式 |
|
||||
|
||||
**`S3_BUCKET` 未设时**:server 启动时打 info 日志 `"S3_BUCKET not set, cloud upload disabled"`,所有上传回落到本地磁盘。
|
||||
|
||||
**对象存储 URL** 按优先级拼装:
|
||||
**公开 URL** 按优先级拼装:
|
||||
|
||||
1. 设了 `CLOUDFRONT_DOMAIN` → `https://<CLOUDFRONT_DOMAIN>/<key>`
|
||||
2. 设了 `AWS_ENDPOINT_URL` → `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>`(path-style)
|
||||
3. 默认走 AWS S3 → `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>`(virtual-hosted-style)。bucket 名含点时会回落到 `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>`(path-style),因为 AWS 通配证书无法覆盖含点 host。
|
||||
|
||||
API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /api/attachments/{id}/download`。这个端点会在安全时跳转到 CloudFront/S3 presigned URL;遇到 `http://rustfs:9000` 这类私有或内网 endpoint 时则由 server 流式转发。Docker/VPC 内部对象存储建议显式设置 `ATTACHMENT_DOWNLOAD_MODE=proxy`。
|
||||
|
||||
### 本地磁盘(S3 未配时)
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
@@ -179,9 +174,6 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | 心跳频率 |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | 任务轮询频率 |
|
||||
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 并发任务上限 |
|
||||
| `MULTICA_AGENT_TIMEOUT` | `0` | 单次任务的绝对墙钟上限;`0` = 不设上限,任务只受看门狗约束(活跃任务不会因为跑得久被杀)。想要硬性成本/资源天花板时再设一个正值 |
|
||||
| `MULTICA_AGENT_IDLE_WATCHDOG` | `30m` | 空闲看门狗:backend 持续静默(无消息、消息队列为空、且没有工具在途)这么久就 force-stop。`0` = 关闭整套看门狗 |
|
||||
| `MULTICA_AGENT_TOOL_WATCHDOG` | `2h` | 工具在途时的静默上限:某个工具调用发出后长时间无任何输出(疑似卡死的子进程)这么久就 force-stop。`0` = 关闭该兜底(在途工具永不被停)|
|
||||
| `MULTICA_<PROVIDER>_PATH` | 对应 CLI 名 | 各 AI 编程工具的可执行文件路径(如 `MULTICA_CLAUDE_PATH`)|
|
||||
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI 编程工具的默认模型 |
|
||||
|
||||
@@ -203,24 +195,18 @@ API 返回的 `download_url` 在未配置 CloudFront 签名时会指向 `GET /ap
|
||||
|
||||
## GitHub 集成
|
||||
|
||||
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个必填环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。另外两个可选变量用于在安装时直接拿到关联账号名。
|
||||
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
|
||||
| `GITHUB_APP_ID` | 空 | 可选。App 设置页上的数字 App ID。配合 `GITHUB_APP_PRIVATE_KEY` 使用,让 setup 回调在安装那一刻直接从 GitHub 取到关联账号名 |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | 空 | 可选。App RSA 私钥的完整 PEM 块(包含 `-----BEGIN/END-----` 两行,保留换行)。用于签发 GitHub App 鉴权 REST 调用所需的短效 JWT |
|
||||
|
||||
**任一必填变量未设时:**
|
||||
**任一变量未设时:**
|
||||
|
||||
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
|
||||
|
||||
**可选 `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` 未设时:**
|
||||
|
||||
- 安装完成后,连接卡片会先短暂显示 `已连接到 unknown`。等 GitHub 的 `installation.created` webhook 到达(通常几秒内),Multica 会把 row 刷成真实的组织/用户名,并通过 realtime 推送让正在打开的 Settings → GitHub 页面无需手动刷新即可更新。
|
||||
|
||||
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
|
||||
|
||||
## 用量统计
|
||||
|
||||
@@ -51,7 +51,7 @@ cd multica
|
||||
make selfhost
|
||||
```
|
||||
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET` and Postgres password, and starts all services via Docker Compose.
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
|
||||
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
|
||||
@@ -63,7 +63,7 @@ Once ready:
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
<Callout>
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, `POSTGRES_PASSWORD`, and the password segment in `DATABASE_URL`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
</Callout>
|
||||
|
||||
### Step 2 — Log In
|
||||
@@ -133,54 +133,17 @@ Alternatively, configure step by step: `multica config set server_url http://loc
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent
|
||||
|
||||
## Usage Dashboard Rollup
|
||||
## Usage Dashboard Rollup (Required)
|
||||
|
||||
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. As of MUL-2957 the backend runs this rollup **in-process** on every replica via a DB-backed scheduler (`sys_cron_executions`). A fresh self-host install needs no operator action — the bundled `pgvector/pgvector:pg17` image works as-is, and you do **not** need to swap it for an image that ships `pg_cron`, register an external cron job, run a systemd timer, or schedule a Kubernetes `CronJob`.
|
||||
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` image does **not** include `pg_cron`, and the backend doesn't run the rollup in-process either — until you schedule it yourself, the dashboard will stay at zero even though `task_usage` is populated.
|
||||
|
||||
Multiple backend replicas are safe: every replica ticks every 30 seconds and tries to claim the current 5-minute UTC plan, but the unique key `(job_name, scope_kind, scope_id, plan_time)` means only one wins each plan. Inspect the audit table to confirm steady-state operation:
|
||||
Pick one supported path before relying on the Usage / Runtime dashboard:
|
||||
|
||||
```sql
|
||||
SELECT plan_time, status, attempt, runner_id,
|
||||
error_code, error_msg, started_at, finished_at
|
||||
FROM sys_cron_executions
|
||||
WHERE job_name = 'rollup_task_usage_hourly'
|
||||
ORDER BY plan_time DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
- **External cron / systemd-timer / Kubernetes `CronJob`**: schedule `SELECT rollup_task_usage_hourly()` every 5 minutes. Idempotent, watermark-driven — overlapping or skipped ticks are safe.
|
||||
- **Postgres with `pg_cron`**: swap the bundled Postgres image for one that ships `pg_cron`, set `shared_preload_libraries=pg_cron`, then `SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', 'SELECT rollup_task_usage_hourly()')` once.
|
||||
- **Backfill historical data**: required on the `v0.3.4 → v0.3.5+` upgrade path when the database already has `task_usage` rows — migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run `./backfill_task_usage_hourly --sleep-between-slices=2s` inside the backend container, then re-run the upgrade and configure one of the schedules above.
|
||||
|
||||
<Callout>
|
||||
**Upgrading from `v0.3.4` to `v0.3.5+`?** As of MUL-2957 the `migrate up` command runs an idempotent monthly-slice backfill automatically right before applying migration `103`, so the upgrade completes in a single invocation — no operator step required. If you are on a pre-MUL-2957 binary or the auto-hook fails for an environmental reason, run `backfill_task_usage_hourly` against the same database and re-run the upgrade. Full recovery flow lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
|
||||
</Callout>
|
||||
|
||||
### Compatibility paths (existing deployments only)
|
||||
|
||||
External schedulers — **`pg_cron` registered on the database, an external cron job, a systemd timer, or a Kubernetes `CronJob`** — that call `SELECT rollup_task_usage_hourly()` directly were the only option before MUL-2957 and remain a supported compatibility path. They are no longer the recommended setup; new deployments should rely on the in-process scheduler. The SQL function holds advisory lock 4246 internally, so the in-process scheduler and any pre-existing external schedule can coexist without ever double-writing the rollup.
|
||||
|
||||
If you already have a `pg_cron` job in production and want to retire it, the safe sequence is:
|
||||
|
||||
1. Confirm the in-process scheduler is healthy on at least one backend replica — recent SUCCESS rows should be landing in `sys_cron_executions` for `rollup_task_usage_hourly`:
|
||||
|
||||
```sql
|
||||
SELECT plan_time, status, runner_id, finished_at
|
||||
FROM sys_cron_executions
|
||||
WHERE job_name = 'rollup_task_usage_hourly'
|
||||
AND status = 'SUCCESS'
|
||||
ORDER BY plan_time DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
2. Once SUCCESS rows are arriving on schedule, unschedule the redundant `pg_cron` entry:
|
||||
|
||||
```sql
|
||||
SELECT cron.unschedule('rollup_task_usage_hourly')
|
||||
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
|
||||
```
|
||||
|
||||
3. Leave the `pg_cron` extension itself installed unless you are sure no other workload depends on it. The bundled `pgvector/pgvector:pg17` image does **not** ship `pg_cron`, so nothing in Multica's default install needs it; uninstalling `pg_cron` from a custom image that other workloads still use is a separate decision.
|
||||
|
||||
External cron / systemd timer / Kubernetes `CronJob` setups that call `SELECT rollup_task_usage_hourly()` directly can be retired the same way — once `sys_cron_executions` shows steady SUCCESS rows from the in-process scheduler, the external job is redundant and can be removed.
|
||||
|
||||
Full reference (audit table semantics, advisory lock 4246, the standalone backfill command, flag descriptions, the migration auto-hook) lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
|
||||
Full reference (Compose + Kubernetes templates, flag descriptions, upgrade order) lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
|
||||
|
||||
## Stopping Services
|
||||
|
||||
@@ -226,8 +189,7 @@ All configuration is done via environment variables. Copy `.env.example` as a st
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string. Keep the password segment in sync with `POSTGRES_PASSWORD`. | `postgres://multica:<postgres-password>@localhost:5432/multica?sslmode=disable` |
|
||||
| `POSTGRES_PASSWORD` | **Must change from default.** Password used by the bundled Postgres container. Keep it in sync with `DATABASE_URL`. | `openssl rand -hex 24` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
---
|
||||
title: GitHub 連携
|
||||
description: GitHub App を一度連携すれば、ブランチ・タイトル・本文にイシュー識別子を含む PR が該当イシューに自動で紐づきます。そして PR をマージするとイシューが Done に移動します。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
**設定 → GitHub** で GitHub アカウントまたは組織を一度だけ連携してください。その後は、ブランチ名・タイトル・本文にイシュー識別子(例: `MUL-123`)を含むあらゆる pull request が該当する[イシュー](/issues)に**自動で紐づき**、イシューサイドバーの **Pull requests** に表示され、PR がマージされるとイシューが **Done** に移動します。
|
||||
|
||||
イシューごとの設定はありません。フロー全体が識別子で駆動されます。
|
||||
|
||||
## 連携が行うこと
|
||||
|
||||
| 場所 | 動作 |
|
||||
|---|---|
|
||||
| **設定 → GitHub** | ワークスペースの admin には、マスタートグル、**Connect GitHub** ボタン、機能スイッチ(PR サイドバー、Co-authored-by、自動紐づけ)を備えた GitHub タブが表示されます。インストール後は GitHub タブに戻ります。 |
|
||||
| **イシューサイドバー → Pull requests** | このイシューに自動で紐づいたすべての PR が、タイトル、リポジトリ、状態(`Open` / `Draft` / `Merged` / `Closed`)、作成者とともに表示されます。行をクリックすると GitHub の該当 PR に移動します。 |
|
||||
| **Webhook(バックグラウンド)** | すべての `pull_request` イベントで、Multica は PR 行を upsert し、PR からイシュー識別子をスキャンして、紐づけ行を(再)構築します。冪等性があり、同じ delivery を再送しても変化はありません。 |
|
||||
| **マージ時のステータス自動変更** | PR が `merged` に遷移すると、まだ `Done` でも `Cancelled` でもない、紐づいたすべてのイシューが `Done` に移動します。ステータス変更は source `github_pr_merged` でタイムラインに記録されます。 |
|
||||
|
||||
ミラーリングされるのは PR 自体のみです。コミット、オープンな PR のないブランチ ref、CI チェックの状態はモデル化され**ません**。この連携は意図的に狭く設計されています。
|
||||
|
||||
## 識別子のマッチング方法
|
||||
|
||||
Webhook は次の順序で 3 つのフィールドから識別子を抽出します: **PR head ブランチ**、**PR タイトル**、**PR 本文**。マッチャーは次のとおりです。
|
||||
|
||||
- 大文字小文字を区別しません — `mul-123`、`MUL-123`、`Mul-123` はすべてマッチします。
|
||||
- 境界があります — 左側の `\b` と右側の数字アンカーにより、`v1.2-3` のようなバージョン番号やメール形式の文字列を誤って拾わないようにしています。
|
||||
- ワークスペーススコープに限定されます — そのワークスペース固有の[イシュー prefix](/workspaces)にのみマッチします。prefix が `MUL` のワークスペースでは、整数が別のイシューと一致しても `FOO-1` は無視されます。
|
||||
- 重複が除去されます — 本文に `MUL-1, MUL-1` と並べても、イシューは一度だけ紐づきます。
|
||||
|
||||
1 つの PR で**複数のイシュー**を参照できます。`Closes MUL-1, MUL-2` は PR を両方のイシューに紐づけ、マージすると両方が `Done` に進みます。
|
||||
|
||||
## マージ時の Done 自動変更ルール
|
||||
|
||||
PR の `merged` フィールドが `true` に切り替わると、紐づいたすべてのイシューが評価されます。
|
||||
|
||||
| イシューの現在のステータス | 結果 |
|
||||
|---|---|
|
||||
| `done` | 変化なし(すでに終了状態)。 |
|
||||
| `cancelled` | **変化なし** — cancelled はユーザーが作業を明示的に放棄したことを意味するため、連携はこのシグナルを上書きしません。 |
|
||||
| それ以外すべて(`todo`、`in_progress`、`in_review`、`blocked`、`backlog`) | `done` に移動。 |
|
||||
|
||||
PR をマージ**せずに**クローズした場合は、PR カードの状態が `Closed` に更新されるだけです。紐づいたイシューはそのまま維持されます — マージせずにクローズすることが何を意味するかはユーザーが決めるからです。
|
||||
|
||||
<Callout type="info">
|
||||
この動作はタイムライン上で `system` アクターに帰属します。イシューの購読者は、人がステータスを移動したときと同じように、ステータス変更に関するインボックス通知を受け取ります。
|
||||
</Callout>
|
||||
|
||||
## 自動で紐づかないもの
|
||||
|
||||
- **コミットメッセージ内の識別子** — ブランチ / タイトル / 本文のみがスキャンされます。`MUL-123: fix login` というタイトルのコミットは、同じ文字列が PR タイトルや本文にも現れない限り自動では紐づきません。
|
||||
- **PR コメント内の識別子** — PR 自体のメタデータのみがスキャンされ、後から付いた GitHub コメントは無視されます。
|
||||
- **App がインストールされていないリポジトリの PR** — App がなければ、Multica は webhook をまったく受け取りません。
|
||||
- **PR をイシューに手動で紐づける** — まだこのための UI はありません。チームの慣習で識別子を Multica が読まない場所に置いている場合は、PR タイトルや本文に追加してください。
|
||||
|
||||
## 連携解除
|
||||
|
||||
**設定 → GitHub** にはインストール一覧はありません — 既存のインストールは GitHub から直接管理します。
|
||||
|
||||
- **GitHub から** — `https://github.com/settings/installations`(個人)または `https://github.com/organizations/<org>/settings/installations`(組織)で Multica GitHub App をアンインストールします。Multica は `installation.deleted` webhook を受け取ってリアルタイムで行を削除し、開いている Settings タブはリロードなしで更新されます。
|
||||
- **Multica 内部からの連携解除は admin 専用です** — GitHub タブの連携解除コントロールは、admin 以外のユーザーには非表示です。マスター GitHub スイッチがオフでも利用可能なままなので、admin はワンクリックで機能を無効化した後でも、古いインストールを取り消せます。
|
||||
|
||||
連携解除後も、ミラーリングされた PR 行はデータベースに残り、過去のイシューサイドバーで何が紐づいていたかを引き続き表示しますが、そのインストールから新たに入ってくる webhook イベントは受理されなくなります。
|
||||
|
||||
## 権限と可視性
|
||||
|
||||
- **連携 / 連携解除**にはワークスペースの **owner または admin** が必要です。member にはカードの説明は見えますが、Connect ボタンは見えません。
|
||||
- イシューの **Pull requests** サイドバーは、そのイシューを閲覧できる誰にでも表示されます — イシュー詳細の他の部分と同じ権限です。
|
||||
- GitHub App は pull request とメタデータへの**読み取り専用**アクセスを要求します。Multica はコミット、コメント、ステータスチェックを GitHub に書き戻すことはありません。
|
||||
|
||||
## セルフホストのセットアップ
|
||||
|
||||
Multica Cloud で Multica を実行している場合、連携はすでに構成済みです — このセクションは飛ばしてください。
|
||||
|
||||
セルフホストの場合は、GitHub App を 1 つ作成し、サーバーを指すように設定し、環境変数を 2 つ設定します。フロー全体は以下のとおりです。
|
||||
|
||||
### 1. GitHub App を作成する
|
||||
|
||||
次のいずれかにアクセスしてください。
|
||||
|
||||
- 個人アカウント → `https://github.com/settings/apps/new`
|
||||
- 組織 → `https://github.com/organizations/<org>/settings/apps/new`
|
||||
|
||||
次を入力します。
|
||||
|
||||
| フィールド | 値 |
|
||||
|---|---|
|
||||
| **GitHub App name** | 見分けやすい名前、例: `Multica` または `Multica (staging)`。 |
|
||||
| **Homepage URL** | Multica フロントエンド、例: `https://multica.example.com`。 |
|
||||
| **Callback URL** | 空欄のままにしてください — Multica は OAuth ユーザー ID を使用しません。 |
|
||||
| **Setup URL** | `https://<api-host>/api/github/setup`。**「Redirect on update」をチェックしてください。** |
|
||||
| **Webhook → Active** | 有効。 |
|
||||
| **Webhook URL** | `https://<api-host>/api/webhooks/github`。 |
|
||||
| **Webhook secret** | 長いランダム文字列を生成してください(例: `openssl rand -hex 32`)。手順 2 で同じ値を Multica の env に貼り付けます。 |
|
||||
| **Permissions → Repository → Pull requests** | **Read-only**。 |
|
||||
| **Permissions → Repository → Metadata** | Read-only(必須)。 |
|
||||
| **Subscribe to events** | **Pull request** をチェックしてください。 |
|
||||
| **Where can this GitHub App be installed?** | お好みで。単一組織のセットアップなら `Only on this account` で十分です。 |
|
||||
|
||||
**Create GitHub App** の後、App の詳細ページから 2 つのことを控えておいてください。
|
||||
|
||||
- 上部の **public link** — その末尾が slug です。`https://github.com/apps/multica-acme` → slug = `multica-acme`。
|
||||
- 先ほど生成した **webhook secret**(後で GitHub から読み戻すことはできません — 今すぐ保存してください)。
|
||||
|
||||
<Callout type="warning">
|
||||
**Webhook secret ≠ Client secret。** App 設定ページには両方のフィールドが並んで配置されています。**Webhook secret** は `pull_request` の payload に署名する値で、Multica が必要とするものです。**Client secret** は OAuth 用で、この連携では使用しません。この 2 つを混同すると、すべての webhook delivery で紛らわしい `401 invalid signature` が発生します。
|
||||
</Callout>
|
||||
|
||||
### 2. 環境変数を設定する
|
||||
|
||||
API サーバーで:
|
||||
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
|
||||
|
||||
# 任意(推奨)— インストール時点で接続済みアカウント名を取得できるため、
|
||||
# 最初の webhook が届くまで待たなくて済みます:
|
||||
GITHUB_APP_ID=<App 設定ページに表示される数値の App ID>
|
||||
GITHUB_APP_PRIVATE_KEY=<BEGIN/END 行を含む完全な PEM ブロック>
|
||||
```
|
||||
|
||||
`GITHUB_APP_SLUG` と `GITHUB_WEBHOOK_SECRET` は必須です。どちらかが欠けていると:
|
||||
|
||||
- Settings の `Connect GitHub` が**無効**になり、「not configured」のヒントが表示されます。
|
||||
- `/api/webhooks/github` エンドポイントが **`503 github webhooks not configured`** を返します — Multica は secret なしでイベントを処理することを拒否し、すべての署名を黙って有効として扱うことはありません。
|
||||
|
||||
`GITHUB_APP_ID` と `GITHUB_APP_PRIVATE_KEY` は**任意**です。これらを設定すると、setup コールバックが GitHub の App 認証された `/app/installations/{id}` エンドポイントを呼び出して、インストール時点で実際の組織名やユーザー名を取得できます。設定しない場合、接続カードには一時的に `Connected to unknown` と表示され、GitHub から `installation.created` webhook が届くと(通常は数秒以内に)Multica が行を更新し、リアルタイムブロードキャストを発行するため、開いている Settings タブは手動更新なしで反映されます。秘密鍵は App 設定ページの **Private keys → Generate a private key** で生成し、PEM ブロック全体(`-----BEGIN/END RSA PRIVATE KEY-----` の行を含む)を改行を保ったまま env 変数に貼り付けてください。
|
||||
|
||||
`FRONTEND_ORIGIN` も設定されている必要があります(どのプロダクションのセルフホストでもすでに設定されています)。インストール後、setup コールバックがユーザーを `<FRONTEND_ORIGIN>/settings?tab=github` に戻します。
|
||||
|
||||
env 変数を設定した後は API を再起動してください。
|
||||
|
||||
### 3. マイグレーションを実行する
|
||||
|
||||
この連携はテーブルをマイグレーション `079_github_integration` で提供します。古いデプロイをアップグレードする場合:
|
||||
|
||||
```bash
|
||||
make migrate-up
|
||||
```
|
||||
|
||||
3 つのテーブルが作成されます: `github_installation`、`github_pull_request`、`issue_pull_request`。これらはワークスペースとともに cascade-delete されるため、ワークスペースを削除すると自動的にクリーンアップされます。
|
||||
|
||||
### 4. UI から連携する
|
||||
|
||||
Multica で:
|
||||
|
||||
1. owner または admin 権限で **設定 → GitHub** を開きます。
|
||||
2. **Connect GitHub** をクリックします。GitHub が新しいタブで開きます。
|
||||
3. アクセスを付与するリポジトリを選び、**Install** します。
|
||||
4. GitHub が `<api-host>/api/github/setup` にリダイレクトしてインストールを記録し、`<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1` に戻します。
|
||||
|
||||
その後、ブランチ / タイトル / 本文にイシュー識別子を含む PR を開いてみてください — 数秒以内に、そのイシューの詳細ページに Pull requests ブロックが表示されます。
|
||||
|
||||
### 5. curl プローブで検証する
|
||||
|
||||
インストール後に GitHub の **Recent Deliveries** ページで `401 invalid signature` が報告される場合、両側の secret が異なっています。どちらが間違っているかを最も速く突き止める方法は、GitHub を迂回することです。
|
||||
|
||||
```bash
|
||||
SECRET="<the value you put in GITHUB_WEBHOOK_SECRET>"
|
||||
BODY='{"zen":"test"}'
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
|
||||
curl -i -X POST https://<api-host>/api/webhooks/github \
|
||||
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||
-H "X-GitHub-Event: ping" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
| HTTP ステータス | 意味 | 解決方法 |
|
||||
|---|---|---|
|
||||
| `200` `{"ok":"pong"}` | サーバーがロードした secret が `$SECRET` と一致します。不一致は GitHub 側にあります。 | App → Webhook secret を編集 → **同じ値を貼り付け** → **Save changes**(保存せずにフィールドの外をクリックすると古い secret が維持されます)。再送してください。 |
|
||||
| `401 invalid signature` | サーバーがロードした secret が思っている値で**ありません**。 | env 変数が実行中のプロセスに反映されたか確認してください(例: `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" \| wc -c`)。再デプロイしてください。 |
|
||||
| `503 github webhooks not configured` | プロセスで `GITHUB_WEBHOOK_SECRET` が空です。 | env 変数を設定し、API を再起動してください。 |
|
||||
|
||||
## 制限事項
|
||||
|
||||
現時点で知っておくべき、いくつかの粗い部分があります。
|
||||
|
||||
- **まだ手動の紐づけ UI はありません** — PR を紐づける唯一の方法は、ブランチ、タイトル、本文に識別子を置くことです。
|
||||
- **CI / チェック状態はありません** — PR 自体のみがミラーリングされます。ビルド状態、レビューコメント、レビュアーは Multica には表示されません。
|
||||
- マージ → Done ルールに対する**ワークスペースレベルの設定はありません** — 固定のデフォルトです(`cancelled` でない限り `merged → done`)。ワークスペースでカスタマイズできるマッピングは将来の追加予定です。
|
||||
- **1 つのイシューに複数の PR が紐づく場合、マージは保守的です** — 2 つの PR がどちらも `MUL-123` を参照していて最初の 1 つがマージされると、イシューはただちに `Done` に移動します。進める前に紐づいたすべての PR が解決されるのを待つ後続の変更が進行中です。
|
||||
|
||||
## 次に
|
||||
|
||||
- [イシュー](/issues) — PR から参照されるイシュー識別子(`MUL-123`)
|
||||
- [ワークスペース](/workspaces) — ワークスペース固有のイシュー prefix を設定する場所
|
||||
- [環境変数](/environment-variables) — 上記の GitHub 変数を含む env の完全なリファレンス
|
||||
@@ -114,20 +114,13 @@ API 서버에서:
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
|
||||
|
||||
# 선택(권장) — 설치 직후 연결된 계정 이름을 바로 확보합니다.
|
||||
# 설정하지 않으면 첫 webhook이 도착할 때까지 대기해야 합니다:
|
||||
GITHUB_APP_ID=<App 설정 페이지에 표시되는 숫자 App ID>
|
||||
GITHUB_APP_PRIVATE_KEY=<BEGIN/END 줄을 포함한 전체 PEM 블록>
|
||||
```
|
||||
|
||||
`GITHUB_APP_SLUG`와 `GITHUB_WEBHOOK_SECRET`은 필수입니다. 둘 중 하나라도 누락되면:
|
||||
두 변수 모두 필수입니다. 둘 중 하나라도 누락되면:
|
||||
|
||||
- Settings의 `Connect GitHub`이 **비활성화**되고 "not configured" 힌트가 표시됩니다.
|
||||
- `/api/webhooks/github` 엔드포인트가 **`503 github webhooks not configured`**를 반환합니다 — Multica는 secret 없이 이벤트를 처리하기를 거부하며, 모든 서명을 조용히 유효한 것으로 취급하지 않습니다.
|
||||
|
||||
`GITHUB_APP_ID`와 `GITHUB_APP_PRIVATE_KEY`는 **선택 사항**입니다. 설정하면 setup 콜백이 GitHub의 App 인증 `/app/installations/{id}` 엔드포인트를 호출해 설치 직후에 실제 조직명/사용자명을 가져옵니다. 설정하지 않으면 연결 카드에 잠시 `Connected to unknown`이 표시되며, GitHub의 `installation.created` 웹훅이 도착하면(보통 몇 초 이내) Multica가 행을 갱신하고 실시간 브로드캐스트를 보내므로 열려 있는 Settings 탭이 수동 새로고침 없이 업데이트됩니다. 비공개 키는 App 설정 페이지의 **Private keys → Generate a private key**에서 생성한 뒤, PEM 블록 전체(`-----BEGIN/END RSA PRIVATE KEY-----` 줄 포함)를 줄바꿈을 유지한 채 env 값에 붙여넣으세요.
|
||||
|
||||
`FRONTEND_ORIGIN`도 설정되어 있어야 합니다(어떤 프로덕션 자체 호스팅이든 이미 설정되어 있습니다). 설치 후 setup 콜백이 사용자를 `<FRONTEND_ORIGIN>/settings?tab=github`으로 다시 돌려보냅니다.
|
||||
|
||||
env 변수를 설정한 후 API를 재시작하세요.
|
||||
|
||||
@@ -114,20 +114,13 @@ On the API server:
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
|
||||
|
||||
# Optional but recommended — populates the connected account name on
|
||||
# install instead of waiting for the first webhook to refresh it:
|
||||
GITHUB_APP_ID=<numeric App ID from the App's settings page>
|
||||
GITHUB_APP_PRIVATE_KEY=<full PEM block, including BEGIN/END lines>
|
||||
```
|
||||
|
||||
`GITHUB_APP_SLUG` and `GITHUB_WEBHOOK_SECRET` are required. If either is missing:
|
||||
Both variables are required. If either is missing:
|
||||
|
||||
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
|
||||
|
||||
`GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` are **optional**. They let the setup callback call GitHub's App-authenticated `/app/installations/{id}` endpoint to fetch the real organization or user name during install. Without them, the connection card briefly shows `Connected to unknown` until GitHub delivers the `installation.created` webhook (typically within a few seconds), at which point Multica refreshes the row and broadcasts a realtime update so any open Settings tab updates without a manual refresh. Generate the private key under **Private keys → Generate a private key** on the App's settings page; paste the full PEM block (including the `-----BEGIN/END RSA PRIVATE KEY-----` lines) into the env var, preserving newlines.
|
||||
|
||||
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings?tab=github` after install.
|
||||
|
||||
Restart the API after setting the env vars.
|
||||
|
||||
@@ -114,19 +114,13 @@ API server 上:
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
|
||||
|
||||
# 可选但建议配置——安装完成时直接拿到关联账号名,而不是等第一次 webhook 才刷新:
|
||||
GITHUB_APP_ID=<App 设置页上的数字 App ID>
|
||||
GITHUB_APP_PRIVATE_KEY=<完整 PEM 块,包含 BEGIN/END 行>
|
||||
```
|
||||
|
||||
`GITHUB_APP_SLUG` 和 `GITHUB_WEBHOOK_SECRET` 必填。任何一个缺失:
|
||||
两个都必填。任何一个缺失:
|
||||
|
||||
- Settings 里 `Connect GitHub` 按钮会被 **disable**,并显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
|
||||
|
||||
`GITHUB_APP_ID` 和 `GITHUB_APP_PRIVATE_KEY` **可选**。配上之后,setup 回调可以用 App JWT 鉴权调用 GitHub `/app/installations/{id}`,安装完成那一刻就拿到真实的组织名/用户名。不配的话,连接卡片会先显示 `已连接到 unknown`,等 GitHub 的 `installation.created` webhook 到达(通常几秒内),Multica 会刷新 row 并通过 realtime 推送让 Settings 页面无需手动刷新即可更新。私钥从 App 设置页 **Private keys → Generate a private key** 生成,把整段 PEM(含 `-----BEGIN/END RSA PRIVATE KEY-----` 两行)粘到 env 里,保留换行。
|
||||
|
||||
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings?tab=github`。
|
||||
|
||||
设完 env 重启 API。
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: Multica の仕組み
|
||||
description: 3 つのコア構成要素(サーバー / デーモン / AI コーディングツール)がどのように連携してエージェントの作業を実行するかを説明します。
|
||||
---
|
||||
|
||||
import { ArchitectureDiagram } from "@/components/architecture-diagram";
|
||||
|
||||
Multica は**分散型**プラットフォームです。あなたが目にする Web インターフェースは表に見えている部分にすぎず、実際の作業は 3 つの構成要素が処理します。**Multica サーバー**はデータを保持します([ワークスペース](/workspaces)、[イシュー](/issues)、[メンバー](/members-roles)、[タスク](/tasks)キューなど)。**[デーモン](/daemon-runtimes)**はあなた自身のマシンで実行され、タスクを取得して AI コーディングツールを駆動します。そして **[AI コーディングツール](/providers)**(Claude Code、Codex、その他のローカル CLI)が、実際にコードを書く構成要素です。これが Multica と Linear や Jira との最大の違いです。**[エージェント](/agents)は当社のサーバーではなく、あなたのマシンで実行されます。**
|
||||
|
||||
## 3 つのコア構成要素
|
||||
|
||||
<ArchitectureDiagram />
|
||||
|
||||
- **Multica サーバー** — あなたが目にするワークスペース、イシュー一覧、コメントスレッドは、すべてここのデータベースに保存されます。また、あなたと同僚の間でリアルタイム更新をプッシュする WebSocket ハブでもあります。エージェントのタスクは**実行しません**。
|
||||
- **デーモン** — Multica CLI の一部であり、あなた自身のマシンで実行されます。起動時にローカルにインストールされた AI コーディングツールを検出し、サーバーに登録したうえで、3 秒ごとにタスクをポーリングし、15 秒ごとにハートビートを送信し始めます。
|
||||
- **AI コーディングツール** — 次の 12 種類のうちの 1 つ(または複数を並列で): [Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。デーモンがタスクを取得した後は、これらのツールを使って実際の作業を行います。
|
||||
|
||||
ツールチェーンがローカルに留まるため、**あなたの API キー、コードディレクトリ、認可されたツール**は、あなたのマシン上でのみ使用されます。Multica サーバーはそのいずれも目にすることはありません。これはセルフホストでも Cloud でも同じように適用されます。
|
||||
|
||||
## タスクのライフサイクル
|
||||
|
||||
最も一般的なシナリオである、イシューをエージェントに割り当てる場合を見てみましょう。
|
||||
|
||||
1. あなたが Web UI で割り当てをクリックします。ブラウザが Multica サーバーへ HTTP リクエストを送ります。
|
||||
2. サーバーがそのイシューの担当者をエージェントに設定し、同時にタスクキューに状態 `queued` の実行タスクを作成します。
|
||||
3. あなたのマシンにあるデーモンが、次のポーリング(3 秒以内)でタスクを取得します。タスクの状態が `dispatched` に変わります。
|
||||
4. デーモンがローカルに隔離された作業ディレクトリを作成し、該当する AI コーディングツールを呼び出します。タスクの状態が `running` に変わります。
|
||||
5. AI がローカルでコードを書き、テストを実行し、コメントをサーバーへ投稿します。
|
||||
6. 実行が終了します。デーモンが結果(成功 / 失敗)をサーバーに報告し、タスクの状態が `completed` または `failed` に変わります。あなたは Web UI で進捗の更新をリアルタイムに(WebSocket を通じて)確認します。
|
||||
|
||||
詳しい動作の仕組みは、[デーモンとランタイム](/daemon-runtimes)および[タスク](/tasks)を参照してください。
|
||||
|
||||
## エージェントを動かす 4 つの方法
|
||||
|
||||
「イシューの割り当て」だけではありません。Multica にはコラボレーションのスタイルごとに 1 つずつ、4 つのトリガーがあります。
|
||||
|
||||
| 方法 | 一般的なシナリオ | ドキュメント |
|
||||
|---|---|---|
|
||||
| **イシューの割り当て** | 最も一般的な方法。イシューをエージェントに割り当てると、自分で作業を始めます | [イシューの割り当て](/assigning-issues) |
|
||||
| **コメントでエージェントを @メンション** | 「これちょっと見てくれる?」— 担当者や状態を変えず、コメント 1 つで実行を開始します | [エージェントのメンション](/mentioning-agents) |
|
||||
| **ダイレクトチャット** | イシューに紐づかない独立した会話 — 質問したり、イシューの下書きを作らせたりします | [チャット](/chat) |
|
||||
| **オートパイロット(スケジュール)** | 常時の指示 — 「毎週月曜の朝にスタンドアップのまとめをして」のようなもの | [オートパイロット](/autopilots) |
|
||||
|
||||
## ランタイム: どこで実行され、ツールは何個か
|
||||
|
||||
**ランタイム**とは「デーモン × 1 つの AI コーディングツール」の組み合わせです。あるマシンのデーモンに Claude Code と Codex の両方がインストールされており、2 つのワークスペースに参加している場合、Multica は 4 つの独立したランタイム(ワークスペース 2 個 × ツール 2 個)を登録します。
|
||||
|
||||
現在は**ローカルデーモン**のランタイムモデルのみがサポートされています。クラウドランタイム(自分のマシンを起動しておく必要がない方式)は**近日提供予定**で、現在はウェイトリストの登録のみを受け付けています。[ダウンロード](https://multica.ai/download)ページでお申し込みください。
|
||||
|
||||
## 次のステップ
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) — 5 分で Multica Cloud に接続する
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — 自前のバックエンドを実行する
|
||||
- [デーモンとランタイム](/daemon-runtimes) — アーキテクチャが依存する構成要素を深掘りする
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user