mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
2 Commits
fix/cloud-
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aed54236f | ||
|
|
5dbd752368 |
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: web-design-guidelines
|
||||
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
argument-hint: <file-or-pattern>
|
||||
---
|
||||
|
||||
# Web Interface Guidelines
|
||||
|
||||
Review files for compliance with Web Interface Guidelines.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Fetch the latest guidelines from the source URL below
|
||||
2. Read the specified files (or prompt user for files/pattern)
|
||||
3. Check against all rules in the fetched guidelines
|
||||
4. Output findings in the terse `file:line` format
|
||||
|
||||
## Guidelines Source
|
||||
|
||||
Fetch fresh guidelines before each review:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||
|
||||
## Usage
|
||||
|
||||
When a user provides a file or pattern argument:
|
||||
1. Fetch guidelines from the source URL above
|
||||
2. Read the specified files
|
||||
3. Apply all rules from the fetched guidelines
|
||||
4. Output findings using the format specified in the guidelines
|
||||
|
||||
If no files specified, ask the user which files to review.
|
||||
72
.env.example
72
.env.example
@@ -29,22 +29,6 @@ PORT=8080
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
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. Leave unset behind a same-origin reverse proxy or for plain
|
||||
# localhost dev — the frontend will compose the URL from
|
||||
# window.origin + webhook_path in that case. Headers are intentionally
|
||||
# not used to derive this value, to avoid Host / X-Forwarded-Host
|
||||
# spoofing when a self-hosted reverse proxy is not hardened.
|
||||
MULTICA_PUBLIC_URL=
|
||||
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
|
||||
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
|
||||
# Empty (the default) means "trust no headers" — the limiter uses
|
||||
# r.RemoteAddr only, which is the safe shape when the backend is
|
||||
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
|
||||
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
|
||||
# announced ranges for cloud deployments.
|
||||
MULTICA_TRUSTED_PROXIES=
|
||||
MULTICA_DAEMON_CONFIG=
|
||||
MULTICA_WORKSPACE_ID=
|
||||
MULTICA_DAEMON_ID=
|
||||
@@ -64,26 +48,11 @@ MULTICA_IMAGE_TAG=latest
|
||||
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
|
||||
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
|
||||
|
||||
# Email
|
||||
# Two delivery options - only one needs to be configured:
|
||||
#
|
||||
# Option A: Resend (SaaS, recommended for cloud deployments)
|
||||
# Set RESEND_API_KEY to a key from resend.com and verify your sending domain there.
|
||||
# For local/dev use, leave RESEND_API_KEY empty - codes print to stdout. To
|
||||
# accept a fixed local code, also set MULTICA_DEV_VERIFICATION_CODE above
|
||||
# (ignored when APP_ENV=production).
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — generated codes print to stdout.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
#
|
||||
# Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
# Takes priority over Resend when SMTP_HOST is set.
|
||||
# Supports unauthenticated relay (leave SMTP_USERNAME empty) and authenticated SMTP.
|
||||
# Set SMTP_TLS_INSECURE=true only for private CA or self-signed certificates.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=25
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_INSECURE=false
|
||||
|
||||
# Google OAuth
|
||||
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
|
||||
@@ -112,13 +81,6 @@ CLOUDFRONT_DOMAIN=
|
||||
# attribute and browsers silently drop such cookies.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
|
||||
# "8760h", "720h30m") or plain integer seconds.
|
||||
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
|
||||
# set a longer value to reduce re-authentication frequency.
|
||||
# Note: longer TTL = longer exposure window if a cookie is leaked.
|
||||
# AUTH_TOKEN_TTL=2592000
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
LOCAL_UPLOAD_DIR=./data/uploads
|
||||
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
@@ -126,30 +88,8 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
# Security
|
||||
# Comma-separated list of allowed origins for CORS and WebSocket connections.
|
||||
# Defaults to localhost dev origins when unset.
|
||||
# Example: CORS_ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# ==================== Rate limiting (optional Redis) ====================
|
||||
# Per-IP fixed-window rate limiter on the public auth endpoints
|
||||
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
|
||||
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
|
||||
# backend logs "rate limiting disabled: REDIS_URL not configured" at
|
||||
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
|
||||
# the PAT cache, and the daemon-token cache.
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
# Max requests per IP per minute. Defaults are 5 for send-code/google
|
||||
# and 20 for verify-code.
|
||||
# RATE_LIMIT_AUTH=5
|
||||
# RATE_LIMIT_AUTH_VERIFY=20
|
||||
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
|
||||
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
|
||||
# REQUIRED behind a reverse proxy — otherwise every real user shares
|
||||
# the proxy IP and the whole deployment lands in one bucket, turning
|
||||
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
|
||||
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
|
||||
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
|
||||
# governs the autopilot webhook limiter).
|
||||
# RATE_LIMIT_TRUSTED_PROXIES=
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
|
||||
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
|
||||
@@ -161,7 +101,7 @@ CORS_ALLOWED_ORIGINS=
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# GitHub App integration (Settings → GitHub "Connect GitHub")
|
||||
# GitHub App integration (Settings → Integrations "Connect GitHub")
|
||||
# Both must be set for the Connect button to enable and for webhooks to be
|
||||
# accepted; leave empty to disable the integration. See docs/github-integration.
|
||||
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -40,7 +40,7 @@ Closes #
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`) and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -91,20 +91,3 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: cd server && go test ./...
|
||||
|
||||
installer:
|
||||
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
|
||||
# backend job so installer regressions surface independently, and
|
||||
# exercised on macOS too because the installer targets macOS/Homebrew
|
||||
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test shell installers
|
||||
run: bash scripts/install.test.sh
|
||||
|
||||
@@ -269,37 +269,21 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
|
||||
|
||||
## Workspaces
|
||||
|
||||
### Working with multiple workspaces
|
||||
|
||||
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
|
||||
|
||||
1. `--workspace-id <id>` flag on the command
|
||||
2. `MULTICA_WORKSPACE_ID` environment variable
|
||||
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
|
||||
|
||||
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
|
||||
|
||||
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
|
||||
|
||||
### List Workspaces
|
||||
|
||||
```bash
|
||||
multica workspace list
|
||||
multica workspace list --full-id
|
||||
multica workspace list --output json
|
||||
```
|
||||
|
||||
The current default workspace is marked with `*`. Table output shows short UUID prefixes — pass `--full-id` when you need the canonical UUIDs.
|
||||
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
|
||||
|
||||
### Switch Default Workspace
|
||||
### Watch / Unwatch
|
||||
|
||||
```bash
|
||||
multica workspace switch <workspace-id>
|
||||
multica workspace switch <slug>
|
||||
multica workspace watch <workspace-id>
|
||||
multica workspace unwatch <workspace-id>
|
||||
```
|
||||
|
||||
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
|
||||
|
||||
### Get Details
|
||||
|
||||
```bash
|
||||
@@ -307,12 +291,10 @@ multica workspace get <workspace-id>
|
||||
multica workspace get <workspace-id> --output json
|
||||
```
|
||||
|
||||
Passing no `<workspace-id>` resolves to the current default workspace, so `multica workspace get` doubles as "what workspace am I on?".
|
||||
|
||||
### List Members
|
||||
|
||||
```bash
|
||||
multica workspace member list <workspace-id>
|
||||
multica workspace members <workspace-id>
|
||||
```
|
||||
|
||||
## Issues
|
||||
@@ -344,7 +326,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace member list --output json` / `multica agent list --output json`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -373,44 +355,9 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
|
||||
### Comments
|
||||
|
||||
```bash
|
||||
# List comments — flat timeline, chronological. Hard cap of 2000 rows; on
|
||||
# long-running issues prefer one of the thread-aware reads below to keep
|
||||
# context windows tight.
|
||||
# List comments
|
||||
multica issue comment list <issue-id>
|
||||
|
||||
# Single thread (root + every descendant). Anchor may be the root itself
|
||||
# or any reply inside the thread — the server walks up to the root.
|
||||
multica issue comment list <issue-id> --thread <comment-id>
|
||||
|
||||
# Single thread, capped to the N most recent replies. The thread root is
|
||||
# always included (even with --tail 0), so an agent landing on a long
|
||||
# thread keeps the "what is this about" context without dragging hundreds
|
||||
# of replies into its prompt.
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30
|
||||
|
||||
# Scroll older replies inside the same thread. --before / --before-id are
|
||||
# the reply cursor that the previous response emitted on stderr as
|
||||
# `Next reply cursor: --before <ts> --before-id <reply-id>`.
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
|
||||
--before <ts> --before-id <reply-id>
|
||||
|
||||
# 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 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 20 \
|
||||
--before <ts> --before-id <root-id>
|
||||
|
||||
# Incremental polling. Combines with --thread or --recent; filters out
|
||||
# replies created on or before <ts> from the page (the thread root is
|
||||
# exempt so the agent always gets context).
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
|
||||
--since <RFC3339-timestamp>
|
||||
|
||||
# Add a comment
|
||||
multica issue comment add <issue-id> --content "Looks good, merging now"
|
||||
|
||||
@@ -421,29 +368,6 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
**`--before` / `--before-id` semantics depend on the paging mode**, by
|
||||
design — same flag, different scope:
|
||||
|
||||
| Mode | What the cursor walks | stderr label |
|
||||
| --- | --- | --- |
|
||||
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
|
||||
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
|
||||
|
||||
Outside those two modes (`--thread` without `--tail`, or no `--thread`
|
||||
and no `--recent`) the cursor flags are rejected so they cannot silently
|
||||
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
|
||||
`X-Multica-Next-Before-Id`) only when an older page actually exists —
|
||||
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
|
||||
replies) intentionally return no cursor so callers stop paginating.
|
||||
|
||||
When `--since` is combined with `--recent` or `--thread --tail`, the
|
||||
server additionally suppresses the cursor once the cursor target itself
|
||||
is older than `since`. Older pages walk strictly older rows, so they
|
||||
cannot satisfy `> since` either — emitting a cursor there would just
|
||||
hand back root-only pages until the caller reaches the start of the
|
||||
thread / issue. Incremental polling stops at the first page whose
|
||||
cursor target falls before the watermark.
|
||||
|
||||
### Subscribers
|
||||
|
||||
```bash
|
||||
@@ -584,8 +508,6 @@ multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
|
||||
|
||||
## Autopilot Commands
|
||||
|
||||
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
|
||||
|
||||
@@ -142,8 +142,6 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
|
||||
| `multica daemon status` | Check daemon status |
|
||||
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
|
||||
| `multica setup self-host` | Same, but for self-hosted deployments |
|
||||
| `multica workspace list` | List your workspaces (current is marked with `*`) |
|
||||
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
|
||||
| `multica issue list` | List issues in your workspace |
|
||||
| `multica issue create` | Create a new issue |
|
||||
| `multica update` | Update to the latest version |
|
||||
|
||||
@@ -25,30 +25,14 @@ These have sensible defaults and only need to be set when tuning a large or cons
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica supports two email backends. `SMTP_HOST` takes priority when set; otherwise `RESEND_API_KEY` is used. With neither configured, verification codes are printed to the server log — copy them from there to log in.
|
||||
|
||||
#### Option A: Resend (recommended for cloud deployments)
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
#### Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
|
||||
Use this option when your deployment cannot reach the public internet or you already have an internal mail relay (e.g. Exchange, Postfix, SendGrid on-prem).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `SMTP_HOST` | SMTP relay hostname (setting this activates SMTP mode) | - |
|
||||
| `SMTP_PORT` | SMTP port | `25` |
|
||||
| `SMTP_USERNAME` | SMTP username (leave empty for unauthenticated relay) | - |
|
||||
| `SMTP_PASSWORD` | SMTP password | - |
|
||||
| `SMTP_TLS_INSECURE` | Set `true` to skip TLS certificate verification (self-signed / private CA certs) | `false` |
|
||||
|
||||
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is not currently supported - use ports 25 or 587 with STARTTLS.
|
||||
|
||||
> **Note:** If neither Resend nor SMTP is configured, generated verification codes are printed to backend logs — copy them from there to log in. A fixed local testing code (e.g. `888888`) is **opt-in only**: set `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env` and keep `APP_ENV` non-production. The Docker self-host stack pins `APP_ENV=production`, so the shortcut is ignored there. **Never enable a fixed code on a publicly reachable instance.**
|
||||
> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
@@ -79,7 +63,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `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 |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `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) |
|
||||
|
||||
@@ -7,7 +7,6 @@ import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { handleAppShortcut } from "./keyboard-shortcuts";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
@@ -190,67 +189,22 @@ function createWindow(): void {
|
||||
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) 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();
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Dev-mode renderer diagnostics. When the renderer crashes hard enough
|
||||
// that DevTools can't be opened (white screen with no clickable surface),
|
||||
// the only way to recover the actual JS error is to forward it from the
|
||||
// main process to the terminal running `make dev`. Without these, the
|
||||
// user sees only the daemon-manager polling noise (`Render frame was
|
||||
// disposed before WebFrameMain could be accessed`) which is a downstream
|
||||
// symptom, not the cause.
|
||||
//
|
||||
// Gated by `is.dev` to keep production stderr clean — packaged builds
|
||||
// don't have a terminal anyway, and we ship to crash-reporting separately.
|
||||
if (is.dev) {
|
||||
const log = (tag: string, ...args: unknown[]) =>
|
||||
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
|
||||
|
||||
// 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.
|
||||
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.
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (errorCode === -3) return;
|
||||
log(
|
||||
"did-fail-load",
|
||||
`code=${errorCode} desc=${errorDescription} url=${validatedURL} mainFrame=${isMainFrame}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 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}`);
|
||||
});
|
||||
}
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
|
||||
|
||||
function makeWc(initialLevel = 0) {
|
||||
let level = initialLevel;
|
||||
return {
|
||||
getZoomLevel: vi.fn(() => level),
|
||||
setZoomLevel: vi.fn((next: number) => {
|
||||
level = next;
|
||||
}),
|
||||
currentLevel: () => level,
|
||||
};
|
||||
}
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
|
||||
): ShortcutInput {
|
||||
return {
|
||||
type: "keyDown",
|
||||
key: k,
|
||||
control: false,
|
||||
meta: false,
|
||||
...mods,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleAppShortcut — reload blocking", () => {
|
||||
it("swallows Cmd+R on macOS", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows Ctrl+R on Linux/Windows", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows F5 regardless of modifier", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-keyDown events", () => {
|
||||
const wc = makeWc();
|
||||
expect(
|
||||
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom in", () => {
|
||||
it("zooms in on Cmd+= (unshifted)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Cmd++ (Shift+=)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Ctrl+= on non-mac", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clamps zoom-in at the upper bound", () => {
|
||||
const wc = makeWc(4.5);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
|
||||
it("zooms out on Cmd+- (unshifted)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Cmd+_ (Shift+-)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Ctrl+- on non-mac", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("undoes a prior Cmd+= so the user can return to 100%", () => {
|
||||
const wc = makeWc(0);
|
||||
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps zoom-out at the lower bound", () => {
|
||||
const wc = makeWc(-3);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(-3);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — reset zoom", () => {
|
||||
it("resets to 0 on Cmd+0", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("resets to 0 on Ctrl+0", () => {
|
||||
const wc = makeWc(-1.5);
|
||||
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores plain 0 without modifier", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — unrelated keys pass through", () => {
|
||||
it("does not capture plain letters", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
|
||||
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { WebContents } from "electron";
|
||||
|
||||
// Shape of the input subset we read from Electron's `before-input-event`.
|
||||
// Modeled as a structural type so the handler is unit-testable without a
|
||||
// real Electron Input instance.
|
||||
export type ShortcutInput = {
|
||||
type: string;
|
||||
key: string;
|
||||
control: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
|
||||
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
|
||||
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
|
||||
|
||||
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
|
||||
// per step). Clamp to a range that keeps the UI legible — values outside
|
||||
// this band turn the workspace into either confetti or a microfiche.
|
||||
const ZOOM_STEP = 0.5;
|
||||
const ZOOM_MIN = -3;
|
||||
const ZOOM_MAX = 4.5;
|
||||
|
||||
/**
|
||||
* Inspect a `before-input-event` key and apply (or block) the matching
|
||||
* window-level shortcut. Returns `true` when the caller should call
|
||||
* `event.preventDefault()` — that both swallows the renderer keydown and
|
||||
* prevents the application menu accelerator from firing, so we don't
|
||||
* double-trigger zoom on macOS where the default menu also binds these
|
||||
* keys.
|
||||
*
|
||||
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
|
||||
* default `Cmd+-` accelerator does not fire reliably across keyboard
|
||||
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
|
||||
* Handling the shortcuts here gives identical behavior on every platform
|
||||
* and every layout.
|
||||
*/
|
||||
export function handleAppShortcut(
|
||||
input: ShortcutInput,
|
||||
webContents: ZoomTarget,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (input.type !== "keyDown") return false;
|
||||
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
|
||||
|
||||
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
|
||||
// (tabs, drafts, WS connections) with no URL bar to recover from.
|
||||
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!cmdOrCtrl) return false;
|
||||
|
||||
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
|
||||
if (input.key === "=" || input.key === "+") {
|
||||
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
|
||||
if (input.key === "-" || input.key === "_") {
|
||||
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + 0 → reset zoom to 100%.
|
||||
if (input.key === "0") {
|
||||
webContents.setZoomLevel(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
|
||||
import { autoUpdater } 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
|
||||
// downloaded and ready to install on next quit.
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Windows arm64 ships its own update metadata channel because
|
||||
@@ -29,39 +26,8 @@ export type ManualUpdateCheckResult =
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
// 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
|
||||
// (see electronjs.org/docs/latest/api/auto-updater). Coalesce concurrent
|
||||
// callers onto the same in-flight promise.
|
||||
let inFlightCheck: Promise<unknown> | null = null;
|
||||
function checkForUpdatesOnce(): Promise<unknown> {
|
||||
if (inFlightCheck) return inFlightCheck;
|
||||
const p = autoUpdater
|
||||
.checkForUpdates()
|
||||
.then((result) => {
|
||||
// checkForUpdates resolves as soon as metadata is fetched; the actual
|
||||
// download (when autoDownload=true) is exposed on result.downloadPromise.
|
||||
// Without a handler a download failure becomes an unhandled rejection
|
||||
// in the main process — Node may terminate it on future versions.
|
||||
void (result as { downloadPromise?: Promise<unknown> } | null)?.downloadPromise?.catch(
|
||||
(err) => {
|
||||
console.error("Failed to download update:", err);
|
||||
},
|
||||
);
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightCheck === p) inFlightCheck = null;
|
||||
});
|
||||
inFlightCheck = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
// Forwarded for renderer-side state tracking only; the notification UI
|
||||
// does not render an "available" affordance with autoDownload=true.
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
@@ -76,20 +42,15 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
win?.webContents.send("updater:update-downloaded");
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("Auto-updater error:", err);
|
||||
});
|
||||
|
||||
// Retained for IPC back-compat with older renderer bundles. With
|
||||
// autoDownload=true the renderer no longer triggers this path.
|
||||
ipcMain.handle("updater:download", () => {
|
||||
return autoUpdater.downloadUpdate();
|
||||
});
|
||||
@@ -100,9 +61,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
|
||||
try {
|
||||
const result = (await checkForUpdatesOnce()) as
|
||||
| { updateInfo: { version: string }; isUpdateAvailable?: boolean }
|
||||
| null;
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
const currentVersion = app.getVersion();
|
||||
// Trust electron-updater's own decision rather than re-deriving it from
|
||||
// a version-string compare. The two diverge for pre-release channels,
|
||||
@@ -126,7 +85,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
// Initial check shortly after startup so we don't block boot.
|
||||
setTimeout(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
@@ -134,7 +93,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
// Background poll so long-running sessions still pick up new releases
|
||||
// without requiring the user to restart the app.
|
||||
setInterval(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Periodic update check failed:", err);
|
||||
});
|
||||
}, PERIODIC_CHECK_INTERVAL_MS);
|
||||
|
||||
4
apps/desktop/src/preload/index.d.ts
vendored
4
apps/desktop/src/preload/index.d.ts
vendored
@@ -84,9 +84,7 @@ interface DaemonAPI {
|
||||
interface UpdaterAPI {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
checkForUpdates: () => Promise<
|
||||
|
||||
@@ -207,11 +207,8 @@ const updaterAPI = {
|
||||
ipcRenderer.on("updater:download-progress", handler);
|
||||
return () => ipcRenderer.removeListener("updater:download-progress", handler);
|
||||
},
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => {
|
||||
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) =>
|
||||
callback(info);
|
||||
onUpdateDownloaded: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("updater:update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
@@ -11,7 +12,15 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,13 +32,24 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Desktop-only controls for the daemon embedded in this Electron app. The
|
||||
* shared runtimes page renders this inside the selected local machine header.
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
*/
|
||||
export function DaemonRuntimeActions() {
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -37,8 +57,14 @@ export function DaemonRuntimeActions() {
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
@@ -50,6 +76,10 @@ export function DaemonRuntimeActions() {
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
@@ -78,6 +108,9 @@ export function DaemonRuntimeActions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
@@ -86,6 +119,8 @@ export function DaemonRuntimeActions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
@@ -101,6 +136,9 @@ export function DaemonRuntimeActions() {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
@@ -124,64 +162,106 @@ export function DaemonRuntimeActions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { useDesktopUnreadBadge } from "@multica/views/platform";
|
||||
@@ -168,6 +169,7 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
{slug && <StarterContentPrompt />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeActions } from "./daemon-runtime-card";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
@@ -19,28 +19,10 @@ import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
*/
|
||||
export function DesktopRuntimesPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
// Remember the last known daemonId/deviceName. After the daemon is
|
||||
// stopped, `status.daemonId` goes back to undefined — without this
|
||||
// sticky cache the local row would either disappear or get reclassified
|
||||
// as a remote machine (since `isCurrent` requires a daemonId match),
|
||||
// taking the Start button with it.
|
||||
const [lastIdentity, setLastIdentity] = useState<{
|
||||
daemonId: string | null;
|
||||
deviceName: string | null;
|
||||
}>({ daemonId: null, deviceName: null });
|
||||
|
||||
useEffect(() => {
|
||||
const apply = (s: DaemonStatus) => {
|
||||
setStatus(s);
|
||||
if (s.daemonId) {
|
||||
setLastIdentity({
|
||||
daemonId: s.daemonId,
|
||||
deviceName: s.deviceName ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
window.daemonAPI.getStatus().then(apply);
|
||||
return window.daemonAPI.onStatusChange(apply);
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
@@ -50,14 +32,7 @@ export function DesktopRuntimesPage() {
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
|
||||
localMachineName={status.deviceName ?? lastIdentity.deviceName}
|
||||
localMachineActions={<DaemonRuntimeActions />}
|
||||
// Desktop owns a local machine for the lifetime of the app, even
|
||||
// while the daemon is stopped or hasn't registered yet. The shared
|
||||
// page synthesizes a placeholder local row when no real runtime
|
||||
// matches, so the Start button is always reachable.
|
||||
hasLocalMachine
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("PageviewTracker", () => {
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires pageview when a foreground tab is added (addTab path)", () => {
|
||||
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
@@ -128,11 +128,7 @@ describe("PageviewTracker", () => {
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Simulate a foreground new-tab action (e.g. an explicit "Open in new
|
||||
// tab" toolbar button that passes `{ activate: true }`) — tC is
|
||||
// appended AND becomes active. `openInNewTab` defaults to background
|
||||
// (no `setActiveTab`); only the `activate: true` branch produces the
|
||||
// state change this test exercises.
|
||||
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tC",
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, fireEvent, within } from "@testing-library/react";
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
togglePin: vi.fn<(tabId: string) => void>(),
|
||||
closeTab: vi.fn<(tabId: string) => void>(),
|
||||
setActiveTab: vi.fn<(tabId: string) => void>(),
|
||||
moveTab: vi.fn<(from: number, to: number) => void>(),
|
||||
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const store = {
|
||||
get activeWorkspaceSlug() {
|
||||
return state.activeWorkspaceSlug;
|
||||
},
|
||||
get byWorkspace() {
|
||||
return state.byWorkspace;
|
||||
},
|
||||
togglePin: state.togglePin,
|
||||
closeTab: state.closeTab,
|
||||
setActiveTab: state.setActiveTab,
|
||||
moveTab: state.moveTab,
|
||||
addTab: state.addTab,
|
||||
};
|
||||
const useTabStore = Object.assign(
|
||||
(selector?: (s: typeof store) => unknown) =>
|
||||
selector ? selector(store) : store,
|
||||
{ getState: () => store },
|
||||
);
|
||||
const useActiveGroup = () =>
|
||||
state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug] ?? null)
|
||||
: null;
|
||||
const resolveRouteIcon = () => "ListTodo";
|
||||
return { useTabStore, useActiveGroup, resolveRouteIcon };
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
paths: {
|
||||
workspace: (slug: string) => ({
|
||||
issues: () => `/${slug}/issues`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { TabBar } from "./tab-bar";
|
||||
|
||||
function reset() {
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
state.togglePin.mockReset();
|
||||
state.closeTab.mockReset();
|
||||
state.setActiveTab.mockReset();
|
||||
state.moveTab.mockReset();
|
||||
state.addTab.mockReset();
|
||||
}
|
||||
|
||||
beforeEach(reset);
|
||||
|
||||
describe("TabBar hover action buttons", () => {
|
||||
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
expect(getAllByLabelText("Unpin tab")).toHaveLength(1);
|
||||
expect(getAllByLabelText("Pin tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clicking the Pin button calls togglePin for the tab", () => {
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
const pinButtons = getAllByLabelText("Pin tab");
|
||||
fireEvent.click(pinButtons[1]); // click Pin on tB (Projects)
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tB");
|
||||
});
|
||||
|
||||
it("clicking the Unpin button on a pinned tab calls togglePin", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
fireEvent.click(getByLabelText("Unpin tab"));
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tA");
|
||||
});
|
||||
|
||||
it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { queryAllByLabelText } = render(<TabBar />);
|
||||
// Only the unpinned tab exposes a Close affordance — pinned tab requires
|
||||
// explicit Unpin first (RFC §3 D3c FINAL).
|
||||
expect(queryAllByLabelText("Close tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
expect(within(pinnedTab).getByText("Issues")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Pin glyph as the leading icon on a pinned tab and the route icon on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
const unpinnedTab = getByLabelText("Projects");
|
||||
// lucide-react renders the icon name into the class list. The leading
|
||||
// slot icon is size-3.5; the hover Pin/Unpin action button is size-2.5,
|
||||
// so we qualify on size to avoid matching the action glyph.
|
||||
expect(pinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeTruthy();
|
||||
expect(pinnedTab.querySelector(".lucide-list-todo")).toBeNull();
|
||||
expect(unpinnedTab.querySelector(".lucide-list-todo.size-3\\.5")).toBeTruthy();
|
||||
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Fragment } from "react";
|
||||
import {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
@@ -9,8 +8,6 @@ import {
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
Pin,
|
||||
PinOff,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -31,20 +28,8 @@ import {
|
||||
restrictToParentElement,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@multica/ui/components/ui/context-menu";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
useTabStore,
|
||||
useActiveGroup,
|
||||
resolveRouteIcon,
|
||||
type Tab,
|
||||
} from "@/stores/tab-store";
|
||||
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -57,23 +42,9 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Settings,
|
||||
};
|
||||
|
||||
function SortableTabItem({
|
||||
tab,
|
||||
isActive,
|
||||
isOnly,
|
||||
}: {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
/**
|
||||
* True iff this is the only tab in the workspace. Hiding X on the last
|
||||
* tab matches existing behavior and avoids the surprise of the store's
|
||||
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
|
||||
*/
|
||||
isOnly: boolean;
|
||||
}) {
|
||||
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const togglePin = useTabStore((s) => s.togglePin);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
@@ -84,11 +55,7 @@ function SortableTabItem({
|
||||
isDragging,
|
||||
} = useSortable({ id: tab.id });
|
||||
|
||||
// Pinned tabs swap the route icon for a Pin glyph as the static "I am
|
||||
// pinned" indicator (RFC §3 D1v-iv FINAL). The route information is still
|
||||
// present in the title, and this avoids a hard left accent border that read
|
||||
// as visually heavy in light mode.
|
||||
const LeadingIcon = tab.pinned ? Pin : TAB_ICONS[tab.icon];
|
||||
const Icon = TAB_ICONS[tab.icon];
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -107,30 +74,17 @@ function SortableTabItem({
|
||||
closeTab(tab.id);
|
||||
};
|
||||
|
||||
const handleTogglePin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
togglePin(tab.id);
|
||||
};
|
||||
|
||||
const stopDragOnAction = (e: React.PointerEvent) => {
|
||||
const stopDragOnClose = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Pinned tabs keep their full title (RFC §3 D1v-ii FINAL). The only visual
|
||||
// differences vs. unpinned tabs are the leading Pin icon (swapped in above)
|
||||
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
|
||||
// reachable via the hover action button below and the right-click menu.
|
||||
const showCloseButton = !tab.pinned && !isOnly;
|
||||
|
||||
const tabButton = (
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
|
||||
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
|
||||
className={cn(
|
||||
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
|
||||
"select-none cursor-default",
|
||||
@@ -140,7 +94,7 @@ function SortableTabItem({
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{LeadingIcon && <LeadingIcon className="size-3.5 shrink-0" />}
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
<span
|
||||
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
|
||||
style={{
|
||||
@@ -150,22 +104,10 @@ function SortableTabItem({
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span
|
||||
onClick={handleTogglePin}
|
||||
onPointerDown={stopDragOnAction}
|
||||
role="button"
|
||||
aria-label={tab.pinned ? "Unpin tab" : "Pin tab"}
|
||||
title={tab.pinned ? "Unpin tab" : "Pin tab"}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
{tab.pinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
|
||||
</span>
|
||||
{showCloseButton && (
|
||||
{!isOnly && (
|
||||
<span
|
||||
onClick={handleClose}
|
||||
onPointerDown={stopDragOnAction}
|
||||
role="button"
|
||||
aria-label="Close tab"
|
||||
onPointerDown={stopDragOnClose}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
@@ -173,36 +115,6 @@ function SortableTabItem({
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={tabButton} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => togglePin(tab.id)}>
|
||||
{tab.pinned ? (
|
||||
<>
|
||||
<PinOff />
|
||||
Unpin tab
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin />
|
||||
Pin tab
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={tab.pinned || isOnly}
|
||||
onClick={() => closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
Close tab
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTabButton() {
|
||||
@@ -243,17 +155,12 @@ export function TabBar() {
|
||||
const tabs = group?.tabs ?? [];
|
||||
const activeTabId = group?.activeTabId ?? "";
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
const pinnedCount = tabs.filter((t) => t.pinned).length;
|
||||
const unpinnedCount = tabs.length - pinnedCount;
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = tabs.findIndex((t) => t.id === active.id);
|
||||
const to = tabs.findIndex((t) => t.id === over.id);
|
||||
// The store clamps the destination to within the source tab's zone
|
||||
// (pinned vs unpinned), so this call is safe even when the user tries
|
||||
// to drag across the boundary — the tab will land at the boundary.
|
||||
if (from !== -1 && to !== -1) moveTab(from, to);
|
||||
};
|
||||
|
||||
@@ -266,22 +173,13 @@ export function TabBar() {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab, index) => (
|
||||
<Fragment key={tab.id}>
|
||||
<SortableTabItem
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
{tab.pinned &&
|
||||
index === pinnedCount - 1 &&
|
||||
unpinnedCount > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-1 h-4 w-px bg-border"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
|
||||
|
||||
// Downloads run silently in the background (main process has
|
||||
// autoDownload=true). The renderer only renders UI once the package is fully
|
||||
// downloaded and waiting for a restart.
|
||||
type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "ready"; version: string };
|
||||
| { status: "available"; version: string }
|
||||
| { status: "downloading"; percent: number }
|
||||
| { status: "ready" };
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [state, setState] = useState<UpdateState>({ status: "idle" });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.updater.onUpdateDownloaded((info) => {
|
||||
setState({ status: "ready", version: info.version });
|
||||
setDismissed(false);
|
||||
});
|
||||
return cleanup;
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateAvailable((info) => {
|
||||
setState({ status: "available", version: info.version });
|
||||
setDismissed(false);
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onDownloadProgress((progress) => {
|
||||
setState({ status: "downloading", percent: progress.percent });
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateDownloaded(() => {
|
||||
setState({ status: "ready" });
|
||||
}),
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
// Prevent double-click: immediately transition to downloading state
|
||||
if (state.status !== "available") return;
|
||||
setState({ status: "downloading", percent: 0 });
|
||||
window.updater.downloadUpdate();
|
||||
}, [state.status]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
window.updater.installUpdate();
|
||||
}, []);
|
||||
|
||||
// Only allow dismiss when update is available (not during download or ready)
|
||||
if (state.status === "idle") return null;
|
||||
if (dismissed) return null;
|
||||
if (dismissed && state.status === "available") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
|
||||
@@ -32,31 +60,78 @@ export function UpdateNotification() {
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} will be applied on next launch.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{state.status === "available" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">New version available</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} is ready to download
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
onClick={handleDownload}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.updater.installUpdate()}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
Download update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "downloading" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Downloading update...</p>
|
||||
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.round(state.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{Math.round(state.percent)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "ready" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{/* Secondary "See changes" — gives the user a reason to
|
||||
restart by surfacing what they're about to get. Opens
|
||||
in the default browser via the shared openExternal
|
||||
bridge so the URL hits the same allow-list as every
|
||||
other outbound link. */}
|
||||
<button
|
||||
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
See changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ export function UpdatesSettingsTab() {
|
||||
<h2 className="text-lg font-semibold">Updates</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The desktop app checks for new versions automatically once an hour and
|
||||
shortly after launch, downloading them in the background. You'll
|
||||
be prompted to restart once an update is ready.
|
||||
shortly after launch.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
@@ -51,8 +50,7 @@ export function UpdatesSettingsTab() {
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Trigger a check now instead of waiting for the next automatic
|
||||
poll. Available updates download in the background and show a
|
||||
restart prompt when ready.
|
||||
poll. Available updates appear as a notification in the corner.
|
||||
</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
@@ -63,8 +61,8 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "available" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
v{state.latestVersion} is downloading in the background —
|
||||
you'll be notified when it's ready to install.
|
||||
v{state.latestVersion} is available — see the download prompt
|
||||
in the corner.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
|
||||
@@ -62,25 +62,18 @@ function WindowOverlayInner() {
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws, issueId) => {
|
||||
onComplete={(ws) => {
|
||||
close();
|
||||
// Runtime-connected onboarding lands on its single guide
|
||||
// issue. Runtime-less exits still land on the issues list.
|
||||
if (ws && issueId) {
|
||||
push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
// Post-onboarding landing is always the workspace issues
|
||||
// list. The welcome-issue flow moved into a dialog that
|
||||
// renders on that page (StarterContentPrompt), so the
|
||||
// flow doesn't need to thread a target issue id back here.
|
||||
if (ws) {
|
||||
push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
push(paths.root());
|
||||
}
|
||||
}}
|
||||
// Restart the bundled daemon when the user hits Refresh on
|
||||
// Step 3. The daemon's PATH probe runs once at boot, so a
|
||||
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
|
||||
// up until the daemon is bounced.
|
||||
onRuntimeRefresh={async () => {
|
||||
await window.daemonAPI?.restart?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { AttachmentPreviewPage } from "@multica/views/attachments";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
export function AttachmentPreviewRoute() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const filename = searchParams.get("name") ?? undefined;
|
||||
|
||||
if (!id) return null;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<AttachmentPreviewPage attachmentId={id} filename={filename} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MemberDetailPage as SharedMemberDetailPage } from "@multica/views/members";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function MemberDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const member = members.find((m) => m.user_id === id) ?? null;
|
||||
|
||||
useDocumentTitle(member?.name ?? "Member");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedMemberDetailPage userId={id} />;
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Shared in-memory state that the mocked tab store reads / mutates. The test
|
||||
// records every method call so we can assert openInNewTab does NOT activate
|
||||
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
|
||||
type MockRouter = {
|
||||
state: { location: { pathname: string } };
|
||||
navigate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
pinned: boolean;
|
||||
router: MockRouter;
|
||||
};
|
||||
|
||||
function makeMockRouter(pathname: string): MockRouter {
|
||||
return {
|
||||
state: { location: { pathname } },
|
||||
navigate: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{
|
||||
id: "tA",
|
||||
path: "/acme/issues",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/acme/issues"),
|
||||
},
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
openTab: vi.fn<(path: string, title?: string, icon?: string) => string>(),
|
||||
setActiveTab: vi.fn<(tabId: string) => void>(),
|
||||
switchWorkspace: vi.fn<(slug: string, openPath?: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const store = {
|
||||
get activeWorkspaceSlug() {
|
||||
return state.activeWorkspaceSlug;
|
||||
},
|
||||
get byWorkspace() {
|
||||
return state.byWorkspace;
|
||||
},
|
||||
openTab: state.openTab,
|
||||
setActiveTab: state.setActiveTab,
|
||||
switchWorkspace: state.switchWorkspace,
|
||||
};
|
||||
const useTabStore = Object.assign(
|
||||
(selector?: (s: typeof store) => unknown) =>
|
||||
selector ? selector(store) : store,
|
||||
{ getState: () => store },
|
||||
);
|
||||
const getActiveTab = () => {
|
||||
const slug = state.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = state.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
|
||||
};
|
||||
const useActiveTabIdentity = () => ({
|
||||
slug: state.activeWorkspaceSlug,
|
||||
tabId: state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
|
||||
: null,
|
||||
});
|
||||
const useActiveTabRouter = () => null;
|
||||
const resolveRouteIcon = () => "File";
|
||||
return {
|
||||
useTabStore,
|
||||
getActiveTab,
|
||||
useActiveTabIdentity,
|
||||
useActiveTabRouter,
|
||||
resolveRouteIcon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/stores/window-overlay-store", () => ({
|
||||
useWindowOverlayStore: Object.assign(
|
||||
() => null,
|
||||
{ getState: () => ({ overlay: null, open: vi.fn(), close: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
() => null,
|
||||
{ getState: () => ({ logout: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
isReservedSlug: (s: string) =>
|
||||
["login", "workspaces", "invite", "onboarding", "invitations"].includes(s),
|
||||
}));
|
||||
|
||||
// DesktopNavigationProvider reads window.desktopAPI.runtimeConfig synchronously.
|
||||
beforeEach(() => {
|
||||
state.openTab.mockReset();
|
||||
state.setActiveTab.mockReset();
|
||||
state.switchWorkspace.mockReset();
|
||||
state.openTab.mockImplementation(() => "tNew");
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{
|
||||
id: "tA",
|
||||
path: "/acme/issues",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/acme/issues"),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, "desktopAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeConfig: { ok: true, config: { appUrl: "https://app.example" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
DesktopNavigationProvider,
|
||||
TabNavigationProvider,
|
||||
} from "./navigation";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
|
||||
function captureAdapter(onAdapter: (adapter: ReturnType<typeof useNavigation>) => void) {
|
||||
function Probe() {
|
||||
const nav = useNavigation();
|
||||
useEffect(() => {
|
||||
onAdapter(nav);
|
||||
}, [nav]);
|
||||
return null;
|
||||
}
|
||||
return Probe;
|
||||
}
|
||||
|
||||
describe("DesktopNavigationProvider.openInNewTab", () => {
|
||||
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
expect(adapter).not.toBeNull();
|
||||
adapter!.openInNewTab!("/acme/agents", "Agents");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates the new tab when opts.activate is true (foreground)", () => {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.openInNewTab!("/acme/agents", "Agents", { activate: true });
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates to switchWorkspace for a cross-workspace path", () => {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.openInNewTab!("/butter/inbox");
|
||||
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DesktopNavigationProvider.push with pinned active tab", () => {
|
||||
function pinActive(pathname: string) {
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: makeMockRouter(pathname),
|
||||
};
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/issues?filter=open");
|
||||
// Pathname unchanged → pinned interception declines and falls through to
|
||||
// the router's own navigate — openTab / setActiveTab must not fire.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves cross-workspace push to the workspace switcher (not pin)", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/butter/inbox");
|
||||
// Cross-workspace push runs through tryRouteToOtherWorkspace before
|
||||
// tryRouteToPinnedNewTab, so switchWorkspace wins.
|
||||
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.openInNewTab", () => {
|
||||
function renderTabProvider() {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname: "/acme/issues", search: "" } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return () => adapter!;
|
||||
}
|
||||
|
||||
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
|
||||
const getAdapter = renderTabProvider();
|
||||
getAdapter().openInNewTab!("/acme/agents", "Agents");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates the new tab when opts.activate is true (foreground)", () => {
|
||||
const getAdapter = renderTabProvider();
|
||||
getAdapter().openInNewTab!("/acme/agents", "Agents", { activate: true });
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.push with pinned active tab", () => {
|
||||
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
|
||||
function renderPinnedTabProvider(pathname: string) {
|
||||
// The active tab and the per-tab router must share the same pathname:
|
||||
// tryRouteToPinnedNewTab reads the *active tab's* router for the current
|
||||
// pathname (so query-only pushes routed via React Router still compare
|
||||
// correctly), while the TabNavigationProvider falls back to *its own*
|
||||
// router.navigate when no interception fires. In real desktop usage they
|
||||
// are the same router instance; this helper mirrors that invariant.
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname, search: "" } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as ProviderRouter;
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: fakeRouter as unknown as MockRouter,
|
||||
};
|
||||
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return { getAdapter: () => adapter!, fakeRouter };
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
// Pinned interception short-circuits — the per-tab router must NOT
|
||||
// navigate, otherwise the pinned tab itself would move off its path.
|
||||
expect(fakeRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/issues?filter=open");
|
||||
// Same pathname → pinned interception declines, push falls through to
|
||||
// the tab's own router.navigate, and no new tab is opened.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open");
|
||||
});
|
||||
});
|
||||
@@ -108,37 +108,6 @@ function tryRouteToOtherWorkspace(path: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept pushes originating in a pinned tab and force them into a new
|
||||
* tab. Returns `true` if the navigation was redirected (caller should NOT
|
||||
* proceed). Pathname-only changes (search / hash / same-page state) are
|
||||
* allowed through so pinned filter / drawer / form-state interactions
|
||||
* still work — see RFC §3 D2a (FINAL: any pathname change → new tab) and
|
||||
* D2b (FINAL: same pathname → allowed in pinned tab).
|
||||
*
|
||||
* Dedupe is preserved (D4a): `openTab` activates an existing same-path tab
|
||||
* if one exists, otherwise creates a new one. The newly-focused tab is
|
||||
* activated foreground — a pinned-tab push is an explicit user action, not
|
||||
* a background cmd+click, so the focus follows.
|
||||
*/
|
||||
function tryRouteToPinnedNewTab(path: string): boolean {
|
||||
const store = useTabStore.getState();
|
||||
const active = getActiveTab(store);
|
||||
if (!active?.pinned) return false;
|
||||
|
||||
// Use the live router pathname rather than `active.path` so query-only
|
||||
// navigations performed via React Router (which only sync pathname back
|
||||
// to the store) still compare correctly.
|
||||
const currentPathname = active.router.state.location.pathname;
|
||||
const newPathname = path.split("?")[0].split("#")[0];
|
||||
if (currentPathname === newPathname) return false;
|
||||
|
||||
const icon = resolveRouteIcon(path);
|
||||
const newId = store.openTab(path, path, icon);
|
||||
if (newId) store.setActiveTab(newId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab
|
||||
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
|
||||
@@ -196,7 +165,6 @@ export function DesktopNavigationProvider({
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
active?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
@@ -210,16 +178,9 @@ export function DesktopNavigationProvider({
|
||||
},
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (
|
||||
path: string,
|
||||
title?: string,
|
||||
opts?: { activate?: boolean },
|
||||
) => {
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
// Cross-workspace "open in new tab" switches workspace and opens
|
||||
// the path there (focus follows the user); same-workspace defaults
|
||||
// to background tab (browser cmd+click semantics). Callers that
|
||||
// represent an explicit "Open in new tab" CTA pass `activate: true`
|
||||
// to bring the new tab to the foreground.
|
||||
// the path there; same-workspace just adds a tab in the current group.
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
@@ -227,10 +188,8 @@ export function DesktopNavigationProvider({
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const newId = store.openTab(path, title ?? path, icon);
|
||||
if (opts?.activate && newId) {
|
||||
store.setActiveTab(newId);
|
||||
}
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
@@ -272,7 +231,6 @@ export function TabNavigationProvider({
|
||||
push: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
@@ -283,11 +241,7 @@ export function TabNavigationProvider({
|
||||
back: () => router.navigate(-1),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (
|
||||
path: string,
|
||||
title?: string,
|
||||
opts?: { activate?: boolean },
|
||||
) => {
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
@@ -295,10 +249,8 @@ export function TabNavigationProvider({
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const newId = store.openTab(path, title ?? path, icon);
|
||||
if (opts?.activate && newId) {
|
||||
store.setActiveTab(newId);
|
||||
}
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
|
||||
@@ -11,9 +11,7 @@ import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { MemberDetailPage } from "./pages/member-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { DashboardPage } from "@multica/views/dashboard";
|
||||
@@ -149,11 +147,6 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{
|
||||
path: "members/:id",
|
||||
element: <MemberDetailPage />,
|
||||
handle: { title: "Member" },
|
||||
},
|
||||
{ path: "squads", element: <SquadsPage />, handle: { title: "Squads" } },
|
||||
{
|
||||
path: "squads/:id",
|
||||
@@ -161,11 +154,6 @@ export const appRoutes: RouteObject[] = [
|
||||
handle: { title: "Squad" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "attachments/:id/preview",
|
||||
element: <AttachmentPreviewRoute />,
|
||||
handle: { title: "Attachment" },
|
||||
},
|
||||
{
|
||||
path: "usage",
|
||||
element: <DashboardPage />,
|
||||
|
||||
@@ -17,7 +17,6 @@ vi.mock("../routes", () => ({
|
||||
import {
|
||||
sanitizeTabPath,
|
||||
migrateV1ToV2,
|
||||
migrateV2ToV3,
|
||||
useTabStore,
|
||||
} from "./tab-store";
|
||||
|
||||
@@ -278,155 +277,3 @@ describe("useTabStore actions", () => {
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePin", () => {
|
||||
it("flips a tab's pinned state", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("moves a newly-pinned tab to the start of the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs[0].id).toBe(agentsId);
|
||||
expect(tabs[0].pinned).toBe(true);
|
||||
expect(tabs[1].pinned).toBe(false);
|
||||
expect(tabs[2].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("appends a second pinned tab after the first pinned tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
store.togglePin(projectsId);
|
||||
|
||||
// Both pinned, in the order they were pinned (agents first, projects
|
||||
// second), then the unpinned default tab.
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([
|
||||
agentsId,
|
||||
projectsId,
|
||||
tabs[2].id,
|
||||
]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it("returns an unpinned tab to the start of the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
|
||||
// Pin both, then unpin one.
|
||||
store.togglePin(issuesId);
|
||||
store.togglePin(projectsId);
|
||||
store.togglePin(issuesId);
|
||||
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTab boundary clamp", () => {
|
||||
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag the pinned tab to index 2 (unpinned zone end).
|
||||
store.moveTab(0, 2);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag agents (index 2) to index 0 (pinned zone).
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// Clamped to index 1 — start of the unpinned zone.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs[1].id).toBe(agentsId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("reorders freely within the same zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
|
||||
// All unpinned; move agents (2) to position 0.
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.path)).toEqual([
|
||||
"/acme/agents",
|
||||
"/acme/issues",
|
||||
"/acme/projects",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateV2ToV3", () => {
|
||||
it("adds pinned=false to every persisted tab", () => {
|
||||
const v2 = {
|
||||
activeWorkspaceSlug: "acme",
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "t1",
|
||||
tabs: [
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const v3 = migrateV2ToV3(v2);
|
||||
expect(v3.activeWorkspaceSlug).toBe("acme");
|
||||
expect(v3.byWorkspace.acme.tabs).toEqual([
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles missing byWorkspace gracefully", () => {
|
||||
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
|
||||
expect(v3.byWorkspace).toEqual({});
|
||||
expect(v3.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,14 +20,6 @@ export interface Tab {
|
||||
router: DataRouter;
|
||||
historyIndex: number;
|
||||
historyLength: number;
|
||||
/**
|
||||
* Pinned tabs render at the left of the tab bar as icon-only, suppress the
|
||||
* X close button, and turn any `navigation.push()` originating in them into
|
||||
* an `openInNewTab()` so they stay parked on their original path. Pinning
|
||||
* is invariant-preserving: pinned tabs always come before unpinned tabs in
|
||||
* a workspace's `tabs` array; `togglePin` / `moveTab` enforce this.
|
||||
*/
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabGroup {
|
||||
@@ -86,20 +78,8 @@ 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;
|
||||
/**
|
||||
* 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
|
||||
* pinned tab into the unpinned zone (or vice versa) is dropped at the
|
||||
* boundary instead. This keeps the "pinned tabs first" invariant without
|
||||
* requiring callers to know about it.
|
||||
*/
|
||||
/** Reorder within the active workspace's group only. */
|
||||
moveTab: (fromIndex: number, toIndex: number) => void;
|
||||
/**
|
||||
* Flip a tab's pinned state. Pinning moves it to the end of the pinned
|
||||
* zone; unpinning moves it to the start of the unpinned zone. Both
|
||||
* preserve the "pinned tabs before unpinned tabs" invariant.
|
||||
*/
|
||||
togglePin: (tabId: string) => void;
|
||||
/**
|
||||
* After the workspace list arrives/changes (login, realtime delete), drop
|
||||
* any tab group whose slug is no longer in `validSlugs`, and repoint
|
||||
@@ -210,17 +190,9 @@ function makeTab(path: string, title: string, icon: string): Tab {
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
pinned: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the first unpinned tab in a group (== pinned count). */
|
||||
function pinnedBoundary(tabs: Tab[]): number {
|
||||
let i = 0;
|
||||
while (i < tabs.length && tabs[i].pinned) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Default entry point for a workspace — its issues list. */
|
||||
function defaultPathFor(slug: string): string {
|
||||
return `/${slug}/issues`;
|
||||
@@ -481,63 +453,17 @@ export const useTabStore = create<TabStore>()(
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
if (fromIndex < 0 || fromIndex >= group.tabs.length) return;
|
||||
|
||||
// Clamp the drop position to within the source tab's group (pinned vs
|
||||
// unpinned) so the "pinned tabs first" invariant survives drag-reorder.
|
||||
// Pinned zone is [0, boundary); unpinned zone is [boundary, length).
|
||||
const boundary = pinnedBoundary(group.tabs);
|
||||
const source = group.tabs[fromIndex];
|
||||
let clampedTo: number;
|
||||
if (source.pinned) {
|
||||
// boundary is exclusive upper bound for pinned-zone indices.
|
||||
clampedTo = Math.max(0, Math.min(toIndex, boundary - 1));
|
||||
} else {
|
||||
clampedTo = Math.max(boundary, Math.min(toIndex, group.tabs.length - 1));
|
||||
}
|
||||
if (clampedTo === fromIndex) return;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
...group,
|
||||
tabs: arrayMove(group.tabs, fromIndex, clampedTo),
|
||||
tabs: arrayMove(group.tabs, fromIndex, toIndex),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
togglePin(tabId) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const nextTab: Tab = { ...current, pinned: !current.pinned };
|
||||
|
||||
// Remove from current position, then insert at the new zone boundary:
|
||||
// pinning → end of pinned zone (just before first unpinned tab)
|
||||
// unpinning → start of unpinned zone (right after last pinned tab)
|
||||
const withoutCurrent = [
|
||||
...group.tabs.slice(0, index),
|
||||
...group.tabs.slice(index + 1),
|
||||
];
|
||||
const newBoundary = pinnedBoundary(withoutCurrent);
|
||||
const insertAt = newBoundary;
|
||||
const nextTabs = [
|
||||
...withoutCurrent.slice(0, insertAt),
|
||||
nextTab,
|
||||
...withoutCurrent.slice(insertAt),
|
||||
];
|
||||
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
let changed = false;
|
||||
@@ -571,23 +497,17 @@ export const useTabStore = create<TabStore>()(
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 3,
|
||||
version: 2,
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
migrate: (persistedState, version) => {
|
||||
// v1 → v2: flat `tabs` array → per-workspace grouping.
|
||||
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
|
||||
// are dropped — they have no workspace to belong to, and the new
|
||||
// model's invariant is "every tab lives in a workspace group".
|
||||
let state = persistedState;
|
||||
if (version < 2 && state && typeof state === "object") {
|
||||
state = migrateV1ToV2(state as Partial<V1Persisted>);
|
||||
if (version < 2 && persistedState && typeof persistedState === "object") {
|
||||
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
|
||||
}
|
||||
// v2 → v3: introduce `Tab.pinned`. Existing tabs default to
|
||||
// unpinned; pin ordering invariant trivially holds (no pinned tabs).
|
||||
if (version < 3 && state && typeof state === "object") {
|
||||
state = migrateV2ToV3(state as V2Persisted);
|
||||
}
|
||||
return state as V3Persisted;
|
||||
return persistedState as V2Persisted;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
activeWorkspaceSlug: state.activeWorkspaceSlug,
|
||||
@@ -597,19 +517,15 @@ export const useTabStore = create<TabStore>()(
|
||||
{
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map(
|
||||
({
|
||||
router: _router,
|
||||
historyIndex: _hi,
|
||||
historyLength: _hl,
|
||||
...rest
|
||||
}) => rest,
|
||||
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
|
||||
rest,
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as Partial<V3Persisted> | undefined;
|
||||
const persisted = persistedState as Partial<V2Persisted> | undefined;
|
||||
if (!persisted?.byWorkspace) return currentState;
|
||||
|
||||
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
|
||||
@@ -636,14 +552,9 @@ export const useTabStore = create<TabStore>()(
|
||||
router: createTabRouter(clean),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
pinned: pTab.pinned === true,
|
||||
});
|
||||
}
|
||||
if (tabs.length === 0) continue;
|
||||
// Enforce the "pinned first" invariant on rehydration in case a
|
||||
// user (or a buggy older write) persisted the pinned tabs out of
|
||||
// order. Stable sort preserves intra-group order.
|
||||
tabs.sort((a, b) => (a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1));
|
||||
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
|
||||
? pGroup.activeTabId
|
||||
: tabs[0].id;
|
||||
@@ -694,38 +605,6 @@ interface V2Persisted {
|
||||
byWorkspace: Record<string, V2PersistedGroup>;
|
||||
}
|
||||
|
||||
interface V3PersistedTab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
interface V3PersistedGroup {
|
||||
tabs: V3PersistedTab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
interface V3Persisted {
|
||||
activeWorkspaceSlug: string | null;
|
||||
byWorkspace: Record<string, V3PersistedGroup>;
|
||||
}
|
||||
|
||||
export function migrateV2ToV3(v2: V2Persisted): V3Persisted {
|
||||
const byWorkspace: Record<string, V3PersistedGroup> = {};
|
||||
for (const [slug, group] of Object.entries(v2.byWorkspace ?? {})) {
|
||||
byWorkspace[slug] = {
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map((t) => ({ ...t, pinned: false })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
activeWorkspaceSlug: v2.activeWorkspaceSlug ?? null,
|
||||
byWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
|
||||
const byWorkspace: Record<string, V2PersistedGroup> = {};
|
||||
const oldTabs = v1.tabs ?? [];
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/projects). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
|
||||
|
||||
## What an agent can do
|
||||
|
||||
@@ -14,7 +14,7 @@ Agents use the same "member" surface as humans, and the UI barely distinguishes
|
||||
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
|
||||
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
|
||||
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
|
||||
- **Lead a [project](/projects)** — it can be set as project lead, same as a human
|
||||
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
|
||||
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
|
||||
|
||||
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 智能体(agent)是 Multica 工作区里的一等公民成员
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/projects) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
|
||||
## 智能体能做什么
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
- **[被分配 issue](/assigning-issues)** —— 作为 assignee,分配后它会自动开工
|
||||
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
|
||||
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
|
||||
- **作为 [project](/projects) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
|
||||
|
||||
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。
|
||||
|
||||
@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace member list --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
|
||||
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
|
||||
|
||||
Unassign:
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`:UUID 来自 `multica workspace member list --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`:UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
|
||||
取消分配:
|
||||
|
||||
|
||||
@@ -12,11 +12,9 @@ For the list of environment variables referenced below, see [Environment variabl
|
||||
|
||||
## How email + verification code sign-in works
|
||||
|
||||
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. Two delivery backends are supported — pick whichever fits your deployment:
|
||||
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. It requires [Resend](https://resend.com/) as the email provider:
|
||||
|
||||
### Option A: Resend (recommended for cloud / public-internet deployments)
|
||||
|
||||
1. Create a [Resend](https://resend.com/) account and verify your domain
|
||||
1. Create a Resend account and verify your domain
|
||||
2. Create an API key
|
||||
3. Set the environment variables:
|
||||
|
||||
@@ -27,22 +25,7 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
|
||||
|
||||
4. Restart the server
|
||||
|
||||
### Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
|
||||
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
|
||||
SMTP_USERNAME=multica # leave empty for unauthenticated relay
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
|
||||
|
||||
**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.
|
||||
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
|
||||
## Fixed local testing codes
|
||||
|
||||
@@ -51,7 +34,7 @@ STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTP
|
||||
|
||||
The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
|
||||
|
||||
Local development without any email backend configured (no Resend, no SMTP) should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -12,11 +12,9 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
## Email + 验证码登录怎么工作
|
||||
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。支持两种邮件发送通道,按部署环境二选一:
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务:
|
||||
|
||||
### Option A:Resend(公网/云端部署推荐)
|
||||
|
||||
1. 在 [Resend](https://resend.com/) 建账号、验证你的域名
|
||||
1. 在 Resend 建账号、验证你的域名
|
||||
2. 创建 API key
|
||||
3. 设环境变量:
|
||||
|
||||
@@ -27,22 +25,7 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
4. 重启 server
|
||||
|
||||
### Option B:SMTP relay(内网/自部署)
|
||||
|
||||
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 587
|
||||
SMTP_USERNAME=multica # 留空则使用未认证 relay
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465(SMTPS / 隐式 TLS),请使用 25 或 587。
|
||||
|
||||
**两种都不配**:server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 固定本地测试验证码
|
||||
|
||||
@@ -51,7 +34,7 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
|
||||
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
|
||||
|
||||
没配任何邮件后端(Resend 和 SMTP 都没设)的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: Let agents start work on a cron schedule, an inbound webhook, or trigger once manually via the UI or CLI.
|
||||
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -16,13 +16,13 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone) or `webhook`
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
## Run it on a schedule
|
||||
@@ -50,109 +50,15 @@ multica autopilot trigger <autopilot-id>
|
||||
|
||||
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
|
||||
|
||||
## Trigger from a webhook
|
||||
|
||||
Autopilots can also fire on inbound HTTP webhooks. Add a **Webhook** trigger
|
||||
on the autopilot detail page; Multica generates a unique URL of the shape:
|
||||
|
||||
```
|
||||
https://<your-multica-host>/api/webhooks/autopilots/awt_…
|
||||
```
|
||||
|
||||
POST any JSON to that URL — Multica records a run with `source = webhook`,
|
||||
stores the body as the run's `trigger_payload`, and dispatches the agent
|
||||
exactly the way a schedule trigger would.
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
|
||||
```
|
||||
|
||||
In **create issue mode**, the inbound payload is appended to the new issue's
|
||||
description so the agent can read it inline. In **run-only mode**, the
|
||||
payload is part of the run context the daemon hands the agent.
|
||||
|
||||
### Payload shape
|
||||
|
||||
You can send your own envelope:
|
||||
|
||||
```json
|
||||
{ "event": "github.pull_request.opened", "eventPayload": { } }
|
||||
```
|
||||
|
||||
…or any JSON object/array. Multica normalizes it into an internal envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "<inferred>",
|
||||
"eventPayload": <your body>,
|
||||
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
|
||||
}
|
||||
```
|
||||
|
||||
When you don't provide an `event` field, Multica infers it from common
|
||||
headers and body fields (`X-GitHub-Event` + body `action`,
|
||||
`X-Gitlab-Event`, `X-Event-Type`, body `event`/`type`/`action`). When
|
||||
nothing matches, the event is `webhook.received`.
|
||||
|
||||
When configuring GitHub or similar sources, set the content type to
|
||||
`application/json` — form-encoded webhook payloads are not accepted.
|
||||
|
||||
### URL is a bearer secret
|
||||
|
||||
The generated URL **is** the credential. Anyone with it can fire the
|
||||
autopilot. Treat it like a token:
|
||||
|
||||
- **Don't paste it into public issue threads, screenshots, or chat history.**
|
||||
- **Rotate it if it leaks** — click "Rotate URL" on the trigger row, or run
|
||||
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`. The
|
||||
old URL stops working immediately.
|
||||
- For sources that require strong source authentication, wait for
|
||||
per-trigger HMAC signature verification; this v1 URL is bearer-only.
|
||||
- Workspace members who can view the autopilot can read its webhook URLs
|
||||
for now — tighter per-role secret visibility is a follow-up.
|
||||
|
||||
### Status-code semantics
|
||||
|
||||
Multica returns `200 OK` with a `status` field for normal no-op outcomes so
|
||||
your provider's webhook-retry machinery doesn't keep hammering the URL:
|
||||
|
||||
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
|
||||
— a run was dispatched.
|
||||
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
|
||||
— the assignee's runtime is offline; recorded as a `skipped` run.
|
||||
- `{"status":"ignored","reason":"trigger_disabled"}` — the trigger is disabled.
|
||||
- `{"status":"ignored","reason":"autopilot_paused"}` — the autopilot is paused.
|
||||
- `{"status":"ignored","reason":"autopilot_archived"}` — the autopilot is archived.
|
||||
|
||||
Non-2xx responses cover real failures:
|
||||
|
||||
- `400` — invalid JSON, scalar body, or empty body.
|
||||
- `404` — unknown token (`{"error":"webhook not found"}`).
|
||||
- `413` — payload exceeded 256 KiB.
|
||||
- `429` — per-token rate limit exceeded (defaults to 60 req/min).
|
||||
|
||||
### Self-hosted: configure your public URL
|
||||
|
||||
When `MULTICA_PUBLIC_URL` is set on the server (e.g. `https://multica.example.com`),
|
||||
the trigger response includes an absolute `webhook_url` and the UI shows a
|
||||
ready-to-copy URL. Without it, the UI composes the URL from the client's
|
||||
API origin — which is fine for desktop and same-origin web, but not for
|
||||
custom self-hosted reverse proxies. Multica deliberately does not derive
|
||||
the public host from `Host` / `X-Forwarded-Host` headers so a misconfigured
|
||||
reverse proxy cannot trick the server into minting webhook URLs pointing at
|
||||
an attacker-controlled host.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual` / `webhook`)
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed` / `skipped`)
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed or skipped)
|
||||
- Failure reason (if failed)
|
||||
|
||||
## What happens when an autopilot fails
|
||||
|
||||
@@ -166,11 +72,7 @@ Why no auto-retry: autopilots are already periodic, so adding system-level retri
|
||||
|
||||
## What's not yet available
|
||||
|
||||
**API-kind triggers are not wired up.** The trigger schema reserves an `api`
|
||||
kind, but no ingress route fires it; the UI shows a Deprecated badge for
|
||||
existing rows and offers no copy/rotate affordances. Per-trigger HMAC
|
||||
signature verification, IP allowlists, and provider-specific event presets
|
||||
are tracked as follow-ups; v1 URLs are bearer-only.
|
||||
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: 让智能体按 cron 定时自己开工,或在 webhook 到来时被触发——也可以通过 UI / CLI 手动触发一次。
|
||||
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -16,13 +16,13 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
|
||||
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
|
||||
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
|
||||
- **执行模式** — 见下节
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)或 `webhook`
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)
|
||||
|
||||
## 选择执行模式
|
||||
|
||||
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
## 让它按时间跑
|
||||
@@ -50,105 +50,15 @@ multica autopilot trigger <autopilot-id>
|
||||
|
||||
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
|
||||
|
||||
## 通过 Webhook 触发
|
||||
|
||||
Autopilot 也可以由入站 HTTP webhook 触发。在详情页添加一个 **Webhook**
|
||||
触发器,Multica 会生成一个唯一的 URL:
|
||||
|
||||
```
|
||||
https://<你的 Multica host>/api/webhooks/autopilots/awt_…
|
||||
```
|
||||
|
||||
向这个 URL POST 任意 JSON——Multica 会记录一条 `source = webhook` 的
|
||||
run,把请求体保存为 run 的 `trigger_payload`,然后按和 schedule 触发器
|
||||
完全一致的方式派发给智能体。
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
|
||||
```
|
||||
|
||||
在**先建 issue 模式**下,入站 payload 会附加在新 issue 的描述里供智能体
|
||||
直接读到;**直跑模式**下,payload 也会随 run 一并交给 daemon。
|
||||
|
||||
### Payload 形态
|
||||
|
||||
可以发自己的封装:
|
||||
|
||||
```json
|
||||
{ "event": "github.pull_request.opened", "eventPayload": { } }
|
||||
```
|
||||
|
||||
也可以直接发任意 JSON 对象 / 数组。Multica 会规范化为内部封装:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "<推断>",
|
||||
"eventPayload": <你的 body>,
|
||||
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
|
||||
}
|
||||
```
|
||||
|
||||
不带 `event` 字段时,Multica 会按以下顺序从常见 header 和 body 字段
|
||||
推断:`X-GitHub-Event` + body `action`,`X-Gitlab-Event`、
|
||||
`X-Event-Type`、body 里的 `event` / `type` / `action`。都不命中时事件
|
||||
名退化为 `webhook.received`。
|
||||
|
||||
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
|
||||
表单编码的 webhook payload 在 v1 里不接受。
|
||||
|
||||
### URL 即 bearer secret
|
||||
|
||||
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:
|
||||
|
||||
- **不要贴到公开 issue 评论、截图、聊天记录里。**
|
||||
- **泄漏后立即重新生成**——在触发器上点"重新生成 URL",或运行
|
||||
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`。
|
||||
旧 URL 立即失效。
|
||||
- 对需要强来源认证的源,等 per-trigger HMAC 签名校验上线;v1 URL 仅
|
||||
bearer。
|
||||
- 当前能查看 Autopilot 的工作区成员都能看到它的 webhook URL——更细的
|
||||
权限可见性是后续工作。
|
||||
|
||||
### 状态码语义
|
||||
|
||||
正常的 no-op 路径都返回 `200 OK` 加 `status` 字段,避免外部 webhook 重试
|
||||
机制反复打:
|
||||
|
||||
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
|
||||
—— 已派发一次 run。
|
||||
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
|
||||
—— 受派智能体的 runtime 离线,记为 `skipped` run。
|
||||
- `{"status":"ignored","reason":"trigger_disabled"}` —— 触发器已禁用。
|
||||
- `{"status":"ignored","reason":"autopilot_paused"}` —— Autopilot 已暂停。
|
||||
- `{"status":"ignored","reason":"autopilot_archived"}` —— Autopilot 已归档。
|
||||
|
||||
非 2xx 是真正的失败:
|
||||
|
||||
- `400` —— 无效 JSON、scalar body、空 body。
|
||||
- `404` —— 未知 token(`{"error":"webhook not found"}`)。
|
||||
- `413` —— 请求体超过 256 KiB。
|
||||
- `429` —— 单 token 速率限制(默认 60 次 / 分钟)。
|
||||
|
||||
### 自托管:配置公开 URL
|
||||
|
||||
服务端设置 `MULTICA_PUBLIC_URL`(例如 `https://multica.example.com`)后,
|
||||
触发器响应里会带绝对的 `webhook_url`,UI 直接显示可复制的 URL。没设
|
||||
时 UI 会用客户端的 API origin 拼出 URL——desktop 和同源 web 没问题,
|
||||
但自定义反向代理就不行了。Multica **故意不**从 `Host` /
|
||||
`X-Forwarded-Host` header 推断公开主机,避免反代配置失误时被诱导生成
|
||||
指向攻击者域名的 webhook URL。
|
||||
|
||||
## 看运行历史
|
||||
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Autopilot 详情页的"历史"tab 看到:
|
||||
|
||||
- 触发源(`schedule` / `manual` / `webhook`)
|
||||
- 触发源(`schedule` / `manual`)
|
||||
- 开始时间、完成时间
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed` / `skipped`)
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed`)
|
||||
- 关联的 issue(先建 issue 模式)或 `task`(直跑模式)
|
||||
- 失败原因(失败或跳过时)
|
||||
- 失败原因(如果失败)
|
||||
|
||||
## Autopilot 失败会怎样
|
||||
|
||||
@@ -162,10 +72,7 @@ curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
|
||||
## 暂不可用的能力
|
||||
|
||||
**API 类型触发器尚未接入。** 触发器 schema 里保留了 `api` 类型但没有
|
||||
入站路由会触发它;UI 会给已有的此类记录打 Deprecated 标签,也不显示
|
||||
copy / rotate 操作。Per-trigger HMAC 签名校验、IP allowlist、按提供方
|
||||
的事件预设是后续工作;v1 URL 仅 bearer。
|
||||
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
|---|---|
|
||||
| `multica workspace list` | List every workspace you can access |
|
||||
| `multica workspace get <slug>` | Show details for one workspace |
|
||||
| `multica workspace member list` | List members of the current workspace |
|
||||
| `multica workspace members` | List members of the current workspace |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
|
||||
|
||||
## Issues and projects
|
||||
|
||||
@@ -39,7 +39,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
|---|---|
|
||||
| `multica workspace list` | 列出你有权访问的所有工作区 |
|
||||
| `multica workspace get <slug>` | 查看一个工作区的详情 |
|
||||
| `multica workspace member list` | 列出当前工作区的成员 |
|
||||
| `multica workspace members` | 列出当前工作区的成员 |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据(admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
|
||||
|
||||
## Issue 和 Project
|
||||
|
||||
@@ -210,7 +210,7 @@ multica workspace get <workspace-id> --output json
|
||||
### List Members
|
||||
|
||||
```bash
|
||||
multica workspace member list <workspace-id>
|
||||
multica workspace members <workspace-id>
|
||||
```
|
||||
|
||||
### Update Workspace
|
||||
@@ -267,7 +267,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID(例如来自 `multica workspace member list --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID(例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
|
||||
### Update Issue
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ multica daemon status
|
||||
|
||||
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
|
||||
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't connect to the server](/troubleshooting#daemon-cant-connect-to-the-server).
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
|
||||
|
||||
## 5. Create an agent
|
||||
|
||||
@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace member list --output json`.
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
|
||||
|
||||
**What happens next from the daemon**:
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
|
||||
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`。
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥);UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
|
||||
|
||||
### Issue keys
|
||||
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
|
||||
|
||||
### Comments in code
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
|
||||
|
||||
### Issue 编号
|
||||
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号,外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效。
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
|
||||
|
||||
### 代码注释
|
||||
|
||||
|
||||
@@ -35,28 +35,14 @@ These are the core variables you must think about before deploying — some have
|
||||
|
||||
## Email configuration
|
||||
|
||||
Multica supports two delivery backends — [Resend](https://resend.com/) for cloud deployments, or an SMTP relay for internal / on-premise networks. `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
|
||||
|
||||
### Resend
|
||||
Multica uses [Resend](https://resend.com/) to send verification codes and invite emails.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | empty | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account; also reused as the `From:` header when SMTP is in use) |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account) |
|
||||
|
||||
### SMTP relay
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | empty | SMTP relay hostname. Setting this activates SMTP mode and overrides Resend |
|
||||
| `SMTP_PORT` | `25` | SMTP port. Use `587` for STARTTLS submission; **port 465 (SMTPS / implicit TLS) is not supported** |
|
||||
| `SMTP_USERNAME` | empty | SMTP username. Leave empty for unauthenticated relay |
|
||||
| `SMTP_PASSWORD` | empty | SMTP password |
|
||||
| `SMTP_TLS_INSECURE` | `false` | Set `true` to skip TLS certificate verification (private CA / self-signed only) |
|
||||
|
||||
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.
|
||||
|
||||
**Behavior when neither is set**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
|
||||
**Behavior when `RESEND_API_KEY` is unset**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
@@ -128,25 +114,6 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
|
||||
|
||||
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
|
||||
|
||||
## Rate limiting (optional Redis)
|
||||
|
||||
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
|
||||
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
|
||||
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
|
||||
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |
|
||||
|
||||
When a request is over the limit, the server replies with `429 Too Many Requests`, `Retry-After: 60`, and body `{"error":"too many requests"}`.
|
||||
|
||||
<Callout type="warning">
|
||||
**Behind a reverse proxy you must set `RATE_LIMIT_TRUSTED_PROXIES`.** Otherwise every real user shares the proxy's IP from the backend's point of view, the whole deployment ends up in one bucket, and `/auth/send-code` becomes 5 req/min for the entire site. Typical values: `127.0.0.1/32,::1/128` for a same-host Caddy / Nginx; the CDN's published ranges for Cloudflare / ALB / CloudFront. Only IPs whose `RemoteAddr` falls inside one of these CIDRs may use `X-Forwarded-For` to identify the client.
|
||||
</Callout>
|
||||
|
||||
This separate `RATE_LIMIT_TRUSTED_PROXIES` is **not** the same as `MULTICA_TRUSTED_PROXIES`, which controls the autopilot-webhook limiter (`/api/webhooks/autopilots/{token}`). Each limiter parses its own list, so a deployment behind a proxy should set both.
|
||||
|
||||
## Daemon tuning parameters
|
||||
|
||||
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:
|
||||
@@ -180,12 +147,12 @@ The [GitHub PR ↔ issue integration](/github-integration) needs two variables.
|
||||
|
||||
| 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_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → Integrations 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 |
|
||||
|
||||
**Behavior when either is unset:**
|
||||
|
||||
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
|
||||
- `Connect GitHub` in Settings → Integrations 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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -35,28 +35,14 @@ Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量
|
||||
|
||||
## 怎么配邮件
|
||||
|
||||
Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合公网部署,SMTP relay 适合内网/自部署。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
|
||||
### Resend
|
||||
Multica 用 [Resend](https://resend.com/) 发验证码和邀请邮件。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | 空 | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名;走 SMTP 时同时作为 `From:` 头)|
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名)|
|
||||
|
||||
### SMTP relay
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | 空 | SMTP relay 主机名。设置后即启用 SMTP 模式并覆盖 Resend |
|
||||
| `SMTP_PORT` | `25` | SMTP 端口。STARTTLS 提交端口用 `587`;**暂不支持 465(SMTPS / 隐式 TLS)** |
|
||||
| `SMTP_USERNAME` | 空 | SMTP 用户名。留空表示未认证 relay |
|
||||
| `SMTP_PASSWORD` | 空 | SMTP 密码 |
|
||||
| `SMTP_TLS_INSECURE` | `false` | 设为 `true` 跳过 TLS 证书校验(仅限私有 CA / 自签证书)|
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。dial 超时 10s,整个 SMTP 会话有 30s deadline,避免 relay 黑洞把 auth handler 挂死。
|
||||
|
||||
**两种都不设的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发方便(你从 server 日志里抄验证码);**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
**不设 `RESEND_API_KEY` 时的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发时方便——你从 server 日志里抄验证码;**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
@@ -128,25 +114,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
|
||||
|
||||
## 速率限制(可选 Redis)
|
||||
|
||||
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**(fail-open),后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `REDIS_URL` | 空 | Redis 连接 URL(例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
|
||||
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
|
||||
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
|
||||
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |
|
||||
|
||||
被限流的请求会返回 `429 Too Many Requests`,带 `Retry-After: 60` 头和 `{"error":"too many requests"}` 响应体。
|
||||
|
||||
<Callout type="warning">
|
||||
**部署在反向代理后面时必须设 `RATE_LIMIT_TRUSTED_PROXIES`。** 否则在后端看来所有真实用户都共用代理那个 IP,整个部署落到同一个桶里,`/auth/send-code` 会变成全站每分钟只能发 5 次。常见值:本机 Caddy / Nginx 用 `127.0.0.1/32,::1/128`;Cloudflare / ALB / CloudFront 用各家公开的 CDN IP 段。只有 `RemoteAddr` 落在这些 CIDR 内的请求才被允许通过 `X-Forwarded-For` 改写客户端 IP。
|
||||
</Callout>
|
||||
|
||||
这里的 `RATE_LIMIT_TRUSTED_PROXIES` 和 `MULTICA_TRUSTED_PROXIES` **不是同一个**变量——后者控制的是 autopilot webhook 端点(`/api/webhooks/autopilots/{token}`)的限流器。两个限流器各自读各自的列表,部署在代理后面的实例需要两个都配上。
|
||||
|
||||
## 守护进程的调节参数
|
||||
|
||||
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:
|
||||
@@ -180,12 +147,12 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → Integrations 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
|
||||
|
||||
**任一变量未设时:**
|
||||
|
||||
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- Settings → Integrations 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
|
||||
|
||||
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
|
||||
|
||||
@@ -219,7 +219,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `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 |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `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) |
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Connect a GitHub App once, then PRs whose branch, title, or body re
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Connect a GitHub account or organization once in **Settings → GitHub**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
|
||||
Connect a GitHub account or organization once in **Settings → Integrations**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
|
||||
|
||||
There is no per-issue setup. The whole flow is identifier-driven.
|
||||
|
||||
@@ -13,7 +13,7 @@ There is no per-issue setup. The whole flow is identifier-driven.
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| **Settings → GitHub** | Workspace admins see the GitHub tab with a master toggle, **Connect GitHub** button, and feature switches (PR sidebar, Co-authored-by, auto-link). After install you bounce back to the GitHub tab. |
|
||||
| **Settings → Integrations** | Workspace admins see a GitHub card with a **Connect GitHub** button. Clicking it opens GitHub's App install page; after install you bounce back to Settings. |
|
||||
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
|
||||
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
|
||||
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
|
||||
@@ -56,10 +56,10 @@ The action is attributed to the `system` actor on the timeline. Subscribers of t
|
||||
|
||||
## Disconnecting
|
||||
|
||||
In **Settings → GitHub** there is no installation list — you manage existing installations from GitHub directly:
|
||||
In **Settings → Integrations** there is no installation list — you manage existing installations from GitHub directly:
|
||||
|
||||
- **From GitHub** — uninstall the Multica GitHub App at `https://github.com/settings/installations` (personal) or `https://github.com/organizations/<org>/settings/installations` (org). Multica receives the `installation.deleted` webhook and drops the row in real time; any open Settings tab updates without a refresh.
|
||||
- **Disconnect from inside Multica is admin-only** — the Disconnect control on the GitHub tab is hidden for non-admins. It stays available even when the master GitHub switch is off, so admins can still revoke a stale installation after one-click-disabling the feature.
|
||||
- **Disconnect from inside Multica is admin-only** — the Settings card is hidden for non-admins.
|
||||
|
||||
After disconnect, mirrored PR rows stay in the database so historical issue sidebars still show what was linked, but no new webhook events from that installation will be accepted.
|
||||
|
||||
@@ -121,7 +121,7 @@ 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.
|
||||
|
||||
`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.
|
||||
`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` after install.
|
||||
|
||||
Restart the API after setting the env vars.
|
||||
|
||||
@@ -139,10 +139,10 @@ Three tables get created: `github_installation`, `github_pull_request`, `issue_p
|
||||
|
||||
In Multica:
|
||||
|
||||
1. Open **Settings → GitHub** as an owner or admin.
|
||||
1. Open **Settings → Integrations** as an owner or admin.
|
||||
2. Click **Connect GitHub**. GitHub opens in a new tab.
|
||||
3. Pick the repositories to grant access to and **Install**.
|
||||
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`.
|
||||
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?github_connected=1`.
|
||||
|
||||
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 一次性连接 GitHub App,之后 PR 的分支名、标题或正
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 **Settings → GitHub** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时,issue 自动转 **Done**。
|
||||
在 **Settings → Integrations** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时,issue 自动转 **Done**。
|
||||
|
||||
没有 per-issue 的配置,整个流程是「编号驱动」的。
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
| 出现位置 | 行为 |
|
||||
|---|---|
|
||||
| **Settings → GitHub** | 工作区 owner / admin 看到 GitHub 这个 tab,里面有主开关、**Connect GitHub** 按钮,以及功能开关(PR 侧栏、Co-authored-by、auto-link)。点 Connect 会打开 GitHub 的 App 安装页;装好后跳回 GitHub tab。 |
|
||||
| **Settings → Integrations** | 工作区 owner / admin 看到一个 GitHub 卡片,里面有 **Connect GitHub** 按钮。点击会打开 GitHub 的 App 安装页;装好后跳回 Settings。 |
|
||||
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR,含标题、仓库、状态(`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
|
||||
| **Webhook(后台)** | 每次 `pull_request` 事件触发:upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
|
||||
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
|
||||
@@ -56,10 +56,10 @@ PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`,issue
|
||||
|
||||
## 断开连接
|
||||
|
||||
**Settings → GitHub** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
|
||||
**Settings → Integrations** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
|
||||
|
||||
- **从 GitHub 卸载** —— 个人在 `https://github.com/settings/installations`、组织在 `https://github.com/organizations/<org>/settings/installations` 卸载 Multica App。Multica 收到 `installation.deleted` webhook 后立刻删行;任何已打开的 Settings tab 实时更新,不用刷新
|
||||
- **Multica 这边的断开是 admin only** —— GitHub tab 上的 Disconnect 控件对非 admin 不显示;主开关关掉时 Disconnect 仍然可用,方便 admin 一键关闭功能后再单独清理已连接的 installation
|
||||
- **Multica 这边的断开是 admin only** —— 卡片对非 admin 不显示连接操作
|
||||
|
||||
断开之后,已经镜像的 PR 行保留在数据库里——历史 issue 侧栏仍能显示当时关联的 PR,但来自这个 installation 的新 webhook 事件不再被接受。
|
||||
|
||||
@@ -121,7 +121,7 @@ GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
|
||||
- Settings 里 `Connect GitHub` 按钮会被 **disable**,并显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
|
||||
|
||||
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings?tab=github`。
|
||||
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings`。
|
||||
|
||||
设完 env 重启 API。
|
||||
|
||||
@@ -139,10 +139,10 @@ make migrate-up
|
||||
|
||||
到 Multica:
|
||||
|
||||
1. 以 owner 或 admin 身份打开 **Settings → GitHub**
|
||||
1. 以 owner 或 admin 身份打开 **Settings → Integrations**
|
||||
2. 点 **Connect GitHub**,GitHub 在新 tab 打开
|
||||
3. 选择要授权的仓库,点 **Install**
|
||||
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`
|
||||
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?github_connected=1`
|
||||
|
||||
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
---
|
||||
title: Install an agent runtime
|
||||
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
|
||||
|
||||
This page is the install-side companion to:
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how detection works
|
||||
- [AI coding tools matrix](/providers) — what each tool can and can't do (session resumption, MCP, model selection)
|
||||
|
||||
<Callout type="info">
|
||||
The Multica server never sees your API keys or the tools themselves. Everything below — installation, authentication, model access — lives on your local machine. If something fails, it's almost always a local problem.
|
||||
</Callout>
|
||||
|
||||
## Before you start
|
||||
|
||||
Two prerequisites apply to **every** tool below:
|
||||
|
||||
1. **The Multica daemon must be running.** Either run `multica daemon start` after installing the [Multica CLI](/cli), or use the [Multica desktop app](/desktop-app), which launches the daemon automatically. Without a running daemon there is nothing to detect tools.
|
||||
2. **The tool's binary must be reachable on `PATH`.** The daemon shells out to each tool by name (see the **Daemon looks for** column in each section). If `which <name>` doesn't find it in your terminal, the daemon won't find it either. After installing, open a fresh terminal (or restart the daemon) so the new `PATH` entry is picked up.
|
||||
|
||||
After installing a tool, restart the daemon:
|
||||
|
||||
```bash
|
||||
multica daemon restart
|
||||
```
|
||||
|
||||
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
|
||||
|
||||
## The 11 supported tools
|
||||
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
|
||||
|
||||
### Claude Code (Anthropic)
|
||||
|
||||
The most complete integration. Session resumption works, MCP works, and it's the **only one of the 11 that actually consumes the `mcp_config` field** on agents (see the [matrix](/providers#mcp-configuration-only-claude-code-actually-reads-it)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `claude` |
|
||||
| Install | Follow the official guide at [claude.com/claude-code](https://www.claude.com/claude-code). The standard route is the npm package `@anthropic-ai/claude-code` (Node.js 18+ required). |
|
||||
| Authentication | Run `claude` once and follow the in-CLI login flow, or set `ANTHROPIC_API_KEY`. |
|
||||
| Notes | First-choice recommendation for new users. |
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `codex` |
|
||||
| Install | Follow the official guide at [github.com/openai/codex](https://github.com/openai/codex). The standard route is the npm package `@openai/codex`. |
|
||||
| Authentication | `codex login` (browser-based) or `OPENAI_API_KEY`. |
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `cursor-agent` |
|
||||
| Install | Install the [Cursor editor](https://cursor.com/) and then the CLI per their docs at [docs.cursor.com](https://docs.cursor.com/). The binary name is `cursor-agent`, not `cursor`. |
|
||||
| Authentication | Sign in through the Cursor editor; the CLI reuses that session. |
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
Model routing goes through your GitHub account entitlement — the tool doesn't pick a model itself; GitHub decides which model you get.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `copilot` |
|
||||
| Install | See GitHub's CLI docs at [github.com/github/copilot-cli](https://github.com/github/copilot-cli). |
|
||||
| Authentication | Browser-based GitHub login through the CLI. |
|
||||
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `gemini` |
|
||||
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
|
||||
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
|
||||
|
||||
### OpenCode (SST)
|
||||
|
||||
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `opencode` |
|
||||
| Install | Follow the official guide at [opencode.ai](https://opencode.ai/) or the GitHub repo at [github.com/sst/opencode](https://github.com/sst/opencode). The typical route is the install script or the npm package. |
|
||||
| Authentication | Configure your model provider(s) per OpenCode's docs (Anthropic, OpenAI, etc.). |
|
||||
|
||||
### Kiro CLI (Amazon)
|
||||
|
||||
ACP-over-stdio transport. Session resumption works through ACP `session/load`; skills are copied into `.kiro/skills/`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `kiro-cli` |
|
||||
| Install | See the Kiro docs at [kiro.dev](https://kiro.dev/). The binary name is `kiro-cli`, not `kiro`. |
|
||||
| Authentication | AWS-account-based; follow Kiro's own onboarding. |
|
||||
|
||||
### Kimi (Moonshot)
|
||||
|
||||
ACP-protocol agent, primarily aimed at the Chinese market. Skills live under `.kimi/skills/` (native discovery).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `kimi` |
|
||||
| Install | Follow the official guide at [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli). |
|
||||
| Authentication | Moonshot API key, configured per the vendor's docs. |
|
||||
|
||||
### Hermes (Nous Research)
|
||||
|
||||
ACP-protocol agent (shares the transport with Kimi). Session resumption works. The skill injection path falls back to the generic `.agent_context/skills/` — verify your skills are loading before relying on them.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `hermes` |
|
||||
| Install | See Nous Research's repository at [github.com/NousResearch](https://github.com/NousResearch) for the latest CLI distribution. |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
### OpenClaw
|
||||
|
||||
Open-source CLI agent orchestrator. **Model is bound at the agent layer** (`openclaw agents add --model`) — it can't be overridden per task, and you can't pass `--model` or `--system-prompt` from Multica.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `openclaw` |
|
||||
| Install | See the project at [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw) (community-maintained). |
|
||||
| Authentication | Configure the underlying model provider per OpenClaw's docs. |
|
||||
|
||||
### Pi (Inflection AI)
|
||||
|
||||
Minimalist. **Session resumption is unusual** — the resume id is the path to a session file on disk, not a string id.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `pi` |
|
||||
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
## After installing
|
||||
|
||||
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
|
||||
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
|
||||
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
|
||||
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`which` finds the binary but the daemon doesn't.** The daemon was started with an older `PATH`. Restart it.
|
||||
- **The binary exists but launching fails.** Run the tool's own `--version` or `--help` once from the terminal — most failures here are missing auth, expired tokens, or a Node.js / runtime mismatch.
|
||||
- **The Runtimes page shows the row, but tasks fail immediately.** Check `multica daemon logs -f` while triggering a task. The daemon surfaces the tool's own error output.
|
||||
|
||||
For broader symptoms, see the [Troubleshooting guide](/troubleshooting).
|
||||
|
||||
## Next
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how detection, heartbeats, and offline handling work
|
||||
- [AI coding tools matrix](/providers) — capability differences once a tool is connected
|
||||
- [Creating and configuring agents](/agents-create) — pick a tool for your agent and start running tasks
|
||||
@@ -1,169 +0,0 @@
|
||||
---
|
||||
title: 安装一个 Agent 运行时
|
||||
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 Multica 里,一个**运行时**(runtime)就是你机器上的守护进程,配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
|
||||
|
||||
这一页是装机的入口,和它配套的是:
|
||||
|
||||
- [守护进程与运行时](/zh/daemon-runtimes) — 检测是怎么工作的
|
||||
- [AI 编程工具矩阵](/zh/providers) — 每款工具的能力差异(会话续接、MCP、模型选择)
|
||||
|
||||
<Callout type="info">
|
||||
Multica 服务器从不接触你的 API key,也不接触工具本身。下面这些操作 —— 安装、登录、模型访问 —— 全部发生在你本机。出问题几乎都是本地问题。
|
||||
</Callout>
|
||||
|
||||
## 开始前
|
||||
|
||||
下面每一款工具都有两个共同前提:
|
||||
|
||||
1. **Multica 守护进程在运行。** 装完 [Multica CLI](/zh/cli) 后跑 `multica daemon start`;或者用 [Multica 桌面端](/zh/desktop-app),它启动时自动拉起守护进程。守护进程没起来,就没人去扫工具。
|
||||
2. **工具的可执行文件在 `PATH` 上。** 守护进程通过名字 shell out 调起工具(见每一节里 **守护进程扫描** 那行的命令名)。终端里 `which <名字>` 找不到,守护进程也找不到。装完后打开新终端(或者重启守护进程),让新的 `PATH` 生效。
|
||||
|
||||
装完一款工具后,重启守护进程:
|
||||
|
||||
```bash
|
||||
multica daemon restart
|
||||
```
|
||||
|
||||
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
|
||||
|
||||
## 11 款支持的工具
|
||||
|
||||
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
|
||||
|
||||
### Claude Code(Anthropic)
|
||||
|
||||
集成最完整的一款。会话续接好用,MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `claude` |
|
||||
| 安装 | 看官方指引 [claude.com/claude-code](https://www.claude.com/claude-code)。常见装法是 npm 包 `@anthropic-ai/claude-code`(需要 Node.js 18+)。 |
|
||||
| 认证 | 跑一次 `claude`,跟着 CLI 里的登录流程走;或者设置 `ANTHROPIC_API_KEY`。 |
|
||||
| 备注 | 新用户首选。 |
|
||||
|
||||
### Codex(OpenAI)
|
||||
|
||||
JSON-RPC 2.0 传输,审批粒度更细。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `codex` |
|
||||
| 安装 | 看官方指引 [github.com/openai/codex](https://github.com/openai/codex)。常见装法是 npm 包 `@openai/codex`。 |
|
||||
| 认证 | `codex login`(浏览器登录),或 `OPENAI_API_KEY`。 |
|
||||
|
||||
### Cursor(Anysphere)
|
||||
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id,你传过去的续接 id 永远无效。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `cursor-agent` |
|
||||
| 安装 | 先装 [Cursor 编辑器](https://cursor.com/),再按 [docs.cursor.com](https://docs.cursor.com/) 的说明装 CLI。可执行文件叫 `cursor-agent`,不是 `cursor`。 |
|
||||
| 认证 | 在 Cursor 编辑器里登录,CLI 复用同一份会话。 |
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
模型走的是你 GitHub 账号的 entitlement —— 工具自己不挑模型,GitHub 决定你拿到哪个模型。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `copilot` |
|
||||
| 安装 | 看 GitHub 的 CLI 文档 [github.com/github/copilot-cli](https://github.com/github/copilot-cli)。 |
|
||||
| 认证 | CLI 里走 GitHub 浏览器登录。 |
|
||||
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
|
||||
|
||||
### Gemini(Google)
|
||||
|
||||
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `gemini` |
|
||||
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
|
||||
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
|
||||
|
||||
### OpenCode(SST)
|
||||
|
||||
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `opencode` |
|
||||
| 安装 | 看官方指引 [opencode.ai](https://opencode.ai/) 或仓库 [github.com/sst/opencode](https://github.com/sst/opencode)。一般是装脚本或 npm 包。 |
|
||||
| 认证 | 按 OpenCode 的文档配你自己的模型供应商(Anthropic、OpenAI 等)。 |
|
||||
|
||||
### Kiro CLI(Amazon)
|
||||
|
||||
ACP-over-stdio 传输。会话续接通过 ACP `session/load` 工作;skills 拷到 `.kiro/skills/`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kiro-cli` |
|
||||
| 安装 | 看 Kiro 的文档 [kiro.dev](https://kiro.dev/)。可执行文件叫 `kiro-cli`,不是 `kiro`。 |
|
||||
| 认证 | 基于 AWS 账号,按 Kiro 自己的引导走。 |
|
||||
|
||||
### Kimi(Moonshot)
|
||||
|
||||
ACP 协议 agent,主要面向中国市场。Skills 放在 `.kimi/skills/`(原生发现路径)。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kimi` |
|
||||
| 安装 | 看官方指引 [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)。 |
|
||||
| 认证 | Moonshot API key,按厂商文档配置。 |
|
||||
|
||||
### Hermes(Nous Research)
|
||||
|
||||
ACP 协议 agent(和 Kimi 共享传输层)。会话续接可用。Skill 注入用的是通用回退路径 `.agent_context/skills/` —— 用之前先验证 skills 真的被加载了。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `hermes` |
|
||||
| 安装 | 看 Nous Research 的仓库 [github.com/NousResearch](https://github.com/NousResearch) 获取最新 CLI。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
### OpenClaw
|
||||
|
||||
开源 CLI agent 编排器。**模型绑在 agent 层**(`openclaw agents add --model`)—— 不能按任务覆盖,从 Multica 也传不了 `--model` / `--system-prompt`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `openclaw` |
|
||||
| 安装 | 看项目 [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw)(社区维护)。 |
|
||||
| 认证 | 按 OpenClaw 的文档配底层模型供应商。 |
|
||||
|
||||
### Pi(Inflection AI)
|
||||
|
||||
极简风格。**会话续接的方式不太一样** —— resume id 是磁盘上的会话文件路径,不是字符串 id。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `pi` |
|
||||
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
## 装完之后
|
||||
|
||||
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
|
||||
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
|
||||
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
|
||||
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。
|
||||
|
||||
## 排错
|
||||
|
||||
- **`which` 找得到,但守护进程找不到。** 守护进程是用旧 `PATH` 启的,重启它。
|
||||
- **可执行文件在,但启动就失败。** 在终端单独跑一次工具的 `--version` 或 `--help`,绝大多数失败都是登录没做、token 过期、Node.js / 运行时版本不对。
|
||||
- **Runtimes 页面看到行,但任务一跑就失败。** 一边触发任务一边跑 `multica daemon logs -f`。守护进程会把工具自己的报错原样吐出来。
|
||||
|
||||
更宽的症状看[排错指南](/zh/troubleshooting)。
|
||||
|
||||
## 接下来
|
||||
|
||||
- [守护进程与运行时](/zh/daemon-runtimes) — 检测、心跳、离线处理
|
||||
- [AI 编程工具矩阵](/zh/providers) — 工具连上之后的能力差异
|
||||
- [创建并配置智能体](/zh/agents-create) — 给你的 agent 挑一款工具,开始跑任务
|
||||
@@ -19,7 +19,6 @@
|
||||
"squads",
|
||||
"---How agents run---",
|
||||
"daemon-runtimes",
|
||||
"install-agent-runtime",
|
||||
"tasks",
|
||||
"providers",
|
||||
"---Collaborating with agents---",
|
||||
|
||||
@@ -45,10 +45,6 @@ Once it's up:
|
||||
- **Frontend**: [http://localhost:3000](http://localhost:3000)
|
||||
- **Backend**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
|
||||
</Callout>
|
||||
|
||||
## 2. Important: keep production safety on
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -63,9 +59,7 @@ Before any public deployment, make sure `.env` has `APP_ENV=production` and `MUL
|
||||
|
||||
Without email configured, your users can't receive verification codes by email; the server prints generated codes to stdout instead.
|
||||
|
||||
Two delivery backends are supported — pick whichever fits your network:
|
||||
|
||||
**Option A — Resend (cloud / public-internet deployments):**
|
||||
To actually send verification emails:
|
||||
|
||||
1. Sign up at [Resend](https://resend.com/) and get an API key
|
||||
2. Verify a sending domain you control
|
||||
@@ -76,80 +70,36 @@ Two delivery backends are supported — pick whichever fits your network:
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**Option B — SMTP relay (internal networks / on-premise):**
|
||||
4. Restart: `docker compose -f docker-compose.selfhost.yml restart backend`
|
||||
|
||||
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set.
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
|
||||
SMTP_USERNAME=multica # leave empty for unauthenticated relay
|
||||
SMTP_PASSWORD=...
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`.
|
||||
|
||||
For more auth configuration (OAuth, signup allowlist) and the full SMTP variable reference, see [Auth setup](/auth-setup) and [Environment variables → Email](/environment-variables#email-configuration).
|
||||
For more auth configuration (OAuth, signup allowlist), see [Auth setup](/auth-setup).
|
||||
|
||||
## 4. First login + create a workspace
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- Enter your email
|
||||
- Grab the verification code from your configured email backend (Resend or SMTP relay); if neither is configured, copy it from the server container stdout — look for the `[DEV] Verification code` line
|
||||
- Grab the verification code from the Resend email (or, if you haven't configured Resend, from the server container stdout — look for the `[DEV] Verification code` line)
|
||||
- Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance
|
||||
- Log in and create your first workspace
|
||||
|
||||
## 5. Point the CLI at your own server
|
||||
|
||||
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one.
|
||||
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one. Once installed, **use the self-host variant of the setup command**:
|
||||
|
||||
### 5a. Same machine
|
||||
```bash
|
||||
multica setup self-host --server-url http://<your-server-address>:8080 --app-url http://<your-server-address>:3000
|
||||
```
|
||||
|
||||
If the CLI and the server run on the same host, the defaults already work:
|
||||
If you're running everything on one local machine:
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
|
||||
That defaults to `http://localhost:8080` (backend) and `http://localhost:3000` (frontend).
|
||||
|
||||
### 5b. Cross-machine: front with a reverse proxy
|
||||
|
||||
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url https://<your-domain> \
|
||||
--app-url https://<your-domain>
|
||||
```
|
||||
|
||||
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
|
||||
|
||||
```nginx
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@ws path /ws /ws/*
|
||||
handle @ws {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)).
|
||||
|
||||
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`.
|
||||
`setup self-host` takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
|
||||
|
||||
## 6. Create an agent + assign your first task
|
||||
|
||||
@@ -158,7 +108,7 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
|
||||
## Common issues
|
||||
|
||||
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
|
||||
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **Verification code not received**: Resend isn't configured → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -44,10 +44,6 @@ make selfhost
|
||||
- **前端**:[http://localhost:3000](http://localhost:3000)
|
||||
- **后端**:[http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS,详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
|
||||
</Callout>
|
||||
|
||||
## 2. 重要:保持生产安全配置
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -62,9 +58,7 @@ make selfhost
|
||||
|
||||
如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。
|
||||
|
||||
支持两种发送通道,按部署环境二选一:
|
||||
|
||||
**Option A — Resend(公网/云端部署):**
|
||||
要真的发验证码邮件:
|
||||
|
||||
1. 在 [Resend](https://resend.com/) 注册并拿一个 API key
|
||||
2. 验证一个你控制的发件域名
|
||||
@@ -75,80 +69,36 @@ make selfhost
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**Option B — SMTP relay(内网/自部署):**
|
||||
4. 重启:`docker compose -f docker-compose.selfhost.yml restart backend`
|
||||
|
||||
适合内网无法访问 `api.resend.com`,或已经有内部邮件中继(Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend。
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 587
|
||||
SMTP_USERNAME=multica # 留空则使用未认证 relay
|
||||
SMTP_PASSWORD=...
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。
|
||||
|
||||
更多 auth 配置(OAuth、注册白名单)以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。
|
||||
更多 auth 配置(OAuth、注册白名单)见 [登录与注册配置](/auth-setup)。
|
||||
|
||||
## 4. 首次登录 + 创建工作区
|
||||
|
||||
打开 [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- 输入你的邮箱
|
||||
- 从你配置的邮件后端(Resend 或 SMTP relay)收到的邮件里拿验证码;两者都没配的话,从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行
|
||||
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行)
|
||||
- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
|
||||
- 登录后创建第一个工作区
|
||||
|
||||
## 5. 连接命令行工具到你自己的 server
|
||||
|
||||
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。
|
||||
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。装好之后,**用 self-host 版本的 setup 命令**:
|
||||
|
||||
### 5a. 同一台机器
|
||||
```bash
|
||||
multica setup self-host --server-url http://<你的服务器地址>:8080 --app-url http://<你的服务器地址>:3000
|
||||
```
|
||||
|
||||
CLI 和 server 在同一台机器上时,默认参数就够用:
|
||||
本地就是一台电脑跑整套的话:
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
会自动连 `http://localhost:8080`(backend)+ `http://localhost:3000`(frontend),引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**。
|
||||
默认连 `http://localhost:8080`(backend)+ `http://localhost:3000`(frontend)。
|
||||
|
||||
### 5b. 跨机访问:用反向代理把服务挡在前面
|
||||
|
||||
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理(Caddy / nginx / Cloudflare Tunnel),由它终结 TLS,再反代到 `127.0.0.1:8080`(backend)和 `127.0.0.1:3000`(frontend)。然后把 CLI 指到公开的 HTTPS 域名:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url https://<你的域名> \
|
||||
--app-url https://<你的域名>
|
||||
```
|
||||
|
||||
最小可用的 Caddyfile,单域名同时挂前后端(带 WebSocket 转发,daemon 和网页端都依赖):
|
||||
|
||||
```nginx
|
||||
multica.example.com {
|
||||
# WebSocket 路由——必须在 catch-all 之前
|
||||
@ws path /ws /ws/*
|
||||
handle @ws {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
# 其它请求 → 前端
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。
|
||||
|
||||
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLS,host 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket),关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。
|
||||
`setup self-host` 会让你在浏览器里完成登录,把 PAT 存到本地,**自动启动守护进程**。
|
||||
|
||||
## 6. 创建智能体 + 分配第一个任务
|
||||
|
||||
@@ -157,7 +107,7 @@ multica.example.com {
|
||||
## 常见问题
|
||||
|
||||
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
|
||||
- **验证码收不到**:没配任何邮件后端(Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
|
||||
- **验证码收不到**:没配 Resend → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
|
||||
- **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上)
|
||||
|
||||
## 下一步
|
||||
|
||||
@@ -126,7 +126,7 @@ There is currently no unarchive command; create a new squad if you need the rout
|
||||
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |
|
||||
|
||||
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace member list --output json`, and `multica squad list --output json`.
|
||||
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace members --output json`, and `multica squad list --output json`.
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agen
|
||||
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
|
||||
|
||||
`--leader` 接受智能体名字或 UUID;其它 ID 从 `multica agent list --output json`、`multica workspace member list --output json`、`multica squad list --output json` 拿。
|
||||
`--leader` 接受智能体名字或 UUID;其它 ID 从 `multica agent list --output json`、`multica workspace members --output json`、`multica squad list --output json` 拿。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -77,9 +77,8 @@ multica issue rerun <issue-id>
|
||||
|
||||
Behavior:
|
||||
|
||||
- By default, targets the issue's **current agent assignee** — useful when you want the rerun to follow the current assignment regardless of who ran the prior task.
|
||||
- The execution-log retry button on a specific row sends that row's task ID alongside, so the rerun targets **the agent that ran that exact task** — not the current assignee. This makes per-row retry meaningful for squad workers, parallel @-mention agents, or rows whose agent has since been displaced by a reassignment.
|
||||
- **Cancels** the target agent's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
|
||||
- Targets the issue's **current agent assignee** — not whoever ran the most recent task. If the assignee changed since the last run, rerun follows the current assignment. To rerun a specific agent that is no longer the assignee, reassign the issue first, then rerun.
|
||||
- **Cancels** the assignee's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
|
||||
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling.
|
||||
- Starts a **fresh agent session** — the prior session ID is **not** inherited. A manual rerun means you've judged the previous output bad, so resuming the same conversation would replay the same poisoned state. (Automatic retry, by contrast, does inherit the session — that path is for infrastructure failures, not bad output.)
|
||||
|
||||
@@ -90,7 +89,7 @@ Comparison:
|
||||
| Trigger | System, based on failure reason | You, manually |
|
||||
| Ceiling | 2 attempts | No limit |
|
||||
| Applicable sources | Issues, chat | Issues with an agent assignee |
|
||||
| Agent picked | Same agent as the failed task | Source task's agent (UI per-row retry) or issue's current assignee (CLI / no task_id) |
|
||||
| Agent picked | Same agent as the failed task | Issue's current assignee |
|
||||
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
|
||||
|
||||
## How a failed task affects issue status
|
||||
|
||||
@@ -77,9 +77,8 @@ multica issue rerun <issue-id>
|
||||
|
||||
行为:
|
||||
|
||||
- 默认跑的是 issue **当前的智能体分配人**——适用于希望 rerun 跟随当前分配人的场景。
|
||||
- 执行日志里某一行的 retry 按钮会把这一行的 task ID 一并发出,rerun 会**针对那一行原本的 agent**,而不是当前分配人。这让 squad worker、并行的 @-mention agent、或者已经被新分配人替代的旧任务行的 retry 按钮都能符合直觉地工作。
|
||||
- **取消**目标 agent 在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了,rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun。
|
||||
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试。
|
||||
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行,再继续之前的对话只会重放被污染的上下文。(自动重试则相反,会继承会话——那条路径处理的是基础设施层面的失败,不是产出不好。)
|
||||
|
||||
@@ -90,7 +89,7 @@ multica issue rerun <issue-id>
|
||||
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
|
||||
| 上限 | 2 次 | 无上限 |
|
||||
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | UI 单行 retry:那一行任务的 agent;CLI / 不带 task_id:issue 当前的分配人 |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
|
||||
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
|
||||
|
||||
## 失败的任务对 issue 状态有什么影响
|
||||
|
||||
@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
|
||||
|
||||
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
|
||||
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
|
||||
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
|
||||
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
|
||||
|
||||
<Callout type="warning">
|
||||
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
|
||||
- **Slug(短链标识符)** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
|
||||
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符。
|
||||
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
|
||||
|
||||
<Callout type="warning">
|
||||
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"fumadocs-ui": "^15.5.2",
|
||||
"lucide-react": "catalog:",
|
||||
"mermaid": "^11.14.0",
|
||||
"next": "^15.5.16",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -17,9 +17,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
|
||||
* web (matching `WindowOverlay` on desktop); content is the shared
|
||||
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
|
||||
*
|
||||
* On complete: runtime-connected onboarding may provide a guide issue id;
|
||||
* navigate there. Otherwise land on the workspace issues list, or root if
|
||||
* the flow never produced a workspace.
|
||||
* On complete: if a workspace was just created, navigate into it;
|
||||
* otherwise fall back to root (proxy / landing picks the user's first ws
|
||||
* or bounces to onboarding if still zero).
|
||||
*
|
||||
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
|
||||
* slot so the flow can render it inside the CLI dialog. The commands it
|
||||
@@ -34,14 +34,6 @@ export default function OnboardingPage() {
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
// The bootstrap path calls refreshMe() before returning, which flips
|
||||
// hasOnboarded to true while the page is still mounted. Without this
|
||||
// flag the guard below races onComplete: the guard's router.replace
|
||||
// (issues list) can overtake onComplete's router.push (guide issue),
|
||||
// dropping the user on the wrong destination. Marking the page as
|
||||
// "completing" right before onComplete navigates keeps the guard
|
||||
// silent for the in-flight transition.
|
||||
const completingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
@@ -49,7 +41,6 @@ export default function OnboardingPage() {
|
||||
return;
|
||||
}
|
||||
if (!workspacesFetched) return;
|
||||
if (completingRef.current) return;
|
||||
// Bounce out only when onboarding genuinely doesn't apply: the user is
|
||||
// already onboarded. We deliberately don't bounce on `workspaces.length`
|
||||
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
|
||||
@@ -71,14 +62,12 @@ export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<OnboardingFlow
|
||||
onComplete={(ws, issueId) => {
|
||||
// Runtime-connected onboarding now creates one focused
|
||||
// onboarding issue. Skip/runtime-less exits still land on the
|
||||
// workspace issues list.
|
||||
completingRef.current = true;
|
||||
if (ws && issueId) {
|
||||
router.push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
onComplete={(ws) => {
|
||||
// No more firstIssueId handoff — the welcome issue is created
|
||||
// inside the workspace via StarterContentPrompt, not during
|
||||
// onboarding. Always land on the workspace issues list (or
|
||||
// root if the flow never produced a workspace).
|
||||
if (ws) {
|
||||
router.push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
router.push(paths.root());
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -15,6 +16,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
|
||||
// Rendered by Next.js as the Suspense fallback during route transitions
|
||||
// inside the (dashboard) segment. Scoped to this segment only — auth /
|
||||
// landing keep their own full-screen fallbacks.
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="flex h-svh w-full flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<Skeleton className="h-5 w-5 rounded-md" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 p-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { MemberDetailPage } from "@multica/views/members";
|
||||
|
||||
export default function MemberDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <MemberDetailPage userId={id} />;
|
||||
}
|
||||
@@ -1,8 +1 @@
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
|
||||
const cloudRuntimeEnabled =
|
||||
process.env.NEXT_PUBLIC_ENABLE_CLOUD_RUNTIME === "true";
|
||||
|
||||
export default function RuntimesRoute() {
|
||||
return <RuntimesPage cloudRuntimeEnabled={cloudRuntimeEnabled} />;
|
||||
}
|
||||
export { RuntimesPage as default } from "@multica/views/runtimes";
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AttachmentPreviewPage } from "@multica/views/attachments";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
// Lives at /:slug/attachments/:id/preview — OUTSIDE the (dashboard) group on
|
||||
// purpose. The dashboard layout adds a left sidebar + top chrome; this page
|
||||
// wants the full viewport for the HTML iframe. Workspace resolution still
|
||||
// happens in the parent [workspaceSlug] layout so useWorkspaceId() works.
|
||||
export default function AttachmentPreviewWebPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const search = useSearchParams();
|
||||
const filename = search.get("name") ?? undefined;
|
||||
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<AttachmentPreviewPage attachmentId={id} filename={filename} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -284,114 +284,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.4",
|
||||
date: "2026-05-20",
|
||||
title: "Smarter Autopilots, Agent Controls & Desktop Reliability",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilots can assign new work through squads and place created Issues directly into a selected Project",
|
||||
"Agent settings now include per-agent thinking controls for Claude and Codex, with an inspector picker that updates instantly",
|
||||
"Desktop tabs can be pinned so important workspace pages stay parked while new links open in fresh tabs",
|
||||
"User profiles can add requester context, giving coding agents better background for assigned Issues",
|
||||
"Workspace settings now have a dedicated GitHub page, and regular members can see connected GitHub installations without admin controls",
|
||||
],
|
||||
improvements: [
|
||||
"New users are guided to connect a runtime instead of receiving starter content that may not match their workspace",
|
||||
"Runtime pages are quieter, and desktop keeps the local machine visible after stopping the local service",
|
||||
"Issue breadcrumbs show the Project segment when an Issue belongs to a Project",
|
||||
"HTML previews and attachment previews have roomier, more predictable layouts",
|
||||
"Squad pages show fuller loading states and use a clearer archive confirmation dialog",
|
||||
"Agents now receive parent and sub-issue handoff guidance before running assigned work",
|
||||
],
|
||||
fixes: [
|
||||
"List editing exits cleanly from an empty top-level item when pressing Enter",
|
||||
"The installer falls back to release binaries when Homebrew setup fails and reports clearer diagnostics",
|
||||
"Retrying an execution log row now reruns the agent that handled that row",
|
||||
"Chat and task-message loading ignore temporary IDs instead of calling invalid task routes",
|
||||
"OpenCode-backed daemon runs no longer enter invisible interactive question prompts",
|
||||
"Gemini runtimes use the correct official icon",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.3",
|
||||
date: "2026-05-19",
|
||||
title: "Project Timelines, Runtime Setup & Clearer Issue Work",
|
||||
changes: [],
|
||||
features: [
|
||||
"Projects now have a Gantt view for scheduled work, with updates that stay in sync as plans change",
|
||||
"Workspace admins can change the issue key prefix from settings",
|
||||
"The CLI can switch between workspaces and show the current workspace",
|
||||
"Agents can read issue threads from the most recent discussion first, making follow-up work easier to route and review",
|
||||
"Usage now includes a one-day view plus weekly trends that respect the selected timezone",
|
||||
"Agent detail pages now work as an issue board for that specific agent",
|
||||
],
|
||||
improvements: [
|
||||
"The onboarding flow now asks one focused question at a time and can guide runtime setup with fewer manual steps",
|
||||
"My Issues now includes squad-assigned work and labels the team-related tab more clearly",
|
||||
"Agent execution logs can be sorted in either direction when reviewing a run",
|
||||
],
|
||||
fixes: [
|
||||
"HTML previews open more predictably from desktop, close the full-screen modal when needed, and support in-page links",
|
||||
"HTML source view and attachment previews are easier to inspect, including opening content in a new tab",
|
||||
"Create-issue prompts no longer keep stale manual draft text when switching modes",
|
||||
"Runtime tasks now find the right workspace instructions and skills from the task folder",
|
||||
"Self-hosted teams can set how long auth sessions last",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title:
|
||||
"Webhook Autopilots, Clearer Workboards & Better Runtime Control",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilots can now start from webhook events, show delivery history, and replay a delivery when a connected system needs another attempt",
|
||||
"Issue boards can group work by assignee, show linked pull request status, and include start dates for clearer planning",
|
||||
"Runtime pages now have a redesigned machine view plus time and task trends in usage charts",
|
||||
"Skills can be copied from local runtimes in bulk, making workspace setup faster",
|
||||
"HTML attachments and HTML code blocks can be previewed directly inside issue discussions",
|
||||
],
|
||||
improvements: [
|
||||
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
|
||||
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
|
||||
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
|
||||
"Search results are ranked more usefully and include better snippets",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot-created issues can repeat reliably and are attributed to the right assignee agent",
|
||||
"Runtime setup now prefers the local machine by default and uses cleaner labels in machine lists",
|
||||
"Squad pages scroll correctly and show which members are already working",
|
||||
"Desktop zoom shortcuts work again across the common keyboard combinations",
|
||||
"Auth, dependency, and local-service updates improve the safety of hosted and self-hosted deployments",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
title: "Faster Navigation, Background Updates & More Reliable Squads",
|
||||
changes: [],
|
||||
features: [
|
||||
"Member and agent detail pages now show related tasks so teams can review who is working on what",
|
||||
"The desktop app downloads updates in the background so a new version is ready when you are",
|
||||
"Self-hosted deployments can send email through SMTP as an alternative to Resend",
|
||||
"Create Squad has a clearer setup flow with member selection that works better for team coordination",
|
||||
],
|
||||
improvements: [
|
||||
"Page transitions are faster, with issue pages prepared ahead of time and smoother loading states",
|
||||
"Long issue activity blocks collapse so comments and conclusions are easier to scan",
|
||||
"Agents and Squads remember the Mine/All view when you return to the list",
|
||||
"Repository setup accepts more SSH URL formats across settings, projects, and quick create",
|
||||
"Squad handoffs are more dependable when agents have multiple roles or delegate to a specific member",
|
||||
],
|
||||
fixes: [
|
||||
"Self-hosted local file cards render and preview correctly",
|
||||
"Agent-run tasks are more dependable when local tools or skills need to be found automatically",
|
||||
"Claude usage totals match more of the model names reported by connected tools",
|
||||
"After switching workspaces, live updates come from the correct workspace and show the right source",
|
||||
"Chat session menus and runtime names hold their shape in narrower spaces",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-05-14",
|
||||
|
||||
@@ -284,113 +284,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.4",
|
||||
date: "2026-05-20",
|
||||
title: "自动任务项目归属、智能体思考设置与更稳的桌面端",
|
||||
changes: [],
|
||||
features: [
|
||||
"自动任务现在可以通过小队分配工作,并把创建的 Issue 直接归入指定项目",
|
||||
"智能体设置新增 Claude 和 Codex 的思考强度控制,并可在详情面板里直接调整",
|
||||
"桌面端标签页可以固定,重要页面会留在左侧,打开新内容时不打断原页面",
|
||||
"用户资料可以补充请求者背景,让代码智能体在处理 Issue 时更理解上下文",
|
||||
"工作区设置新增 GitHub 专页,普通成员也能查看已连接的 GitHub 安装信息",
|
||||
],
|
||||
improvements: [
|
||||
"新用户引导会优先创建连接运行环境的下一步,不再生成不合适的示例内容",
|
||||
"运行环境页面减少重复信息,桌面端停止本机服务后仍能看到本机行并重新启动",
|
||||
"Issue 面包屑会显示所属项目,查看来源更清楚",
|
||||
"HTML 预览和附件预览拥有更合适的默认尺寸,查看内容更自然",
|
||||
"小队列表加载状态更完整,归档小队时会使用更清晰的确认弹窗",
|
||||
"智能体运行前会收到父 Issue / 子 Issue 协作规则,完成子任务后的回传更稳定",
|
||||
],
|
||||
fixes: [
|
||||
"在空的顶层列表项按 Enter 时,编辑器可以正常退出列表",
|
||||
"安装脚本在 Homebrew 失败时会自动改用发行版文件,并显示更清楚的诊断信息",
|
||||
"从执行记录重试时,会重新唤起当时处理该记录的智能体",
|
||||
"聊天和任务消息加载会跳过临时 ID,避免访问无效任务",
|
||||
"OpenCode 运行环境不再进入看不见的交互提问流程",
|
||||
"Gemini 运行环境使用正确的官方图标",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.3",
|
||||
date: "2026-05-19",
|
||||
title: "项目时间线、运行环境设置与更清晰的任务协作",
|
||||
changes: [],
|
||||
features: [
|
||||
"项目现在提供甘特图视图,用于查看有排期的工作,并会在计划变化时实时同步",
|
||||
"Workspace 管理员可以在设置中调整 Issue 编号前缀",
|
||||
"命令行可以切换 workspace 并查看当前 workspace",
|
||||
"Agent 现在可以优先读取最新的 Issue 讨论线程,后续跟进和审查更贴近当前上下文",
|
||||
"Usage 新增 1 天视图和按周趋势,并会遵循所选时区",
|
||||
"Agent 详情页现在是对应智能体的 Issue 看板",
|
||||
],
|
||||
improvements: [
|
||||
"Onboarding 改为一次回答一个问题,并能用更少步骤引导 runtime 设置",
|
||||
"My Issues 会包含分配给小队的工作,相关标签也更容易理解",
|
||||
"查看智能体执行日志时可以切换排序方向,回看运行过程更方便",
|
||||
],
|
||||
fixes: [
|
||||
"桌面端打开 HTML 预览更稳定,必要时会关闭全屏窗口,并支持页面内链接跳转",
|
||||
"HTML 源码视图和附件预览更容易检查,也可以把内容打开到新标签页",
|
||||
"切换创建 Issue 模式时,提示词里不再残留旧的手写草稿",
|
||||
"Runtime 任务会从任务目录读取正确的 workspace 指令和 skills",
|
||||
"自托管团队可以设置登录会话有效期",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title: "Webhook 自动任务、更清晰的工作看板与更稳的运行环境",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilot 现在可以由 webhook 事件触发,并能查看投递记录,在外部系统需要时重新投递一次",
|
||||
"Issue 看板支持按负责人分组,展示关联 Pull Request 状态,并加入开始日期,排期更清楚",
|
||||
"Runtime 页面升级了机器视图,并在用量图表中加入时间和任务趋势",
|
||||
"Skills 支持从本地 runtime 批量复制到 workspace,团队初始化更快",
|
||||
"HTML 附件和 HTML 代码块可以直接在 Issue 讨论中预览",
|
||||
],
|
||||
improvements: [
|
||||
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
|
||||
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
|
||||
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
|
||||
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot 创建 Issue 时可以稳定重复触发,并正确归属到负责的 assignee agent",
|
||||
"Runtime 设置默认优先选择本地机器,机器列表中的名称也更清晰",
|
||||
"Squad 页面可以正常滚动,并能看到成员当前是否已经在处理工作",
|
||||
"桌面端缩放快捷键在常见组合下恢复正常",
|
||||
"登录、安全补丁和本地服务配置更新,让托管版和自托管部署都更安全",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
title: "更快的导航、后台更新与更可靠的小队协作",
|
||||
changes: [],
|
||||
features: [
|
||||
"成员和 agent 详情页现在可以看到关联任务,方便回看每个人和每个 agent 正在推进的工作",
|
||||
"桌面端会在后台提前下载新版本,等你准备好时再安装更新",
|
||||
"自托管部署可以使用 SMTP 发送邮件,不再只依赖 Resend",
|
||||
"创建 Squad 的流程更清晰,成员选择和初始设置更适合团队协作",
|
||||
],
|
||||
improvements: [
|
||||
"页面切换更快,Issue 页面会提前准备内容,并在加载时展示更自然的过渡状态",
|
||||
"Issue 时间线会把较长的活动记录收起,重点评论和结论更容易扫读",
|
||||
"Agents 和 Squads 页会记住你上次选择的 Mine/All 视图,返回列表时不再重置",
|
||||
"仓库设置、项目资源和快速创建流程更好地支持 SSH 形式的仓库地址",
|
||||
"小队分工更稳定,leader 能正确接续双角色 agent 的回复,也会更明确地把任务交给指定成员",
|
||||
],
|
||||
fixes: [
|
||||
"自托管本地文件卡片可以正常展示和预览",
|
||||
"Agent 在自动寻找本地工具、加载技能以及无人值守运行时更可靠",
|
||||
"Claude 用量统计能识别更多接入工具上报的模型名称",
|
||||
"切换 workspace 后,实时更新会来自正确的 workspace,消息来源也更准确",
|
||||
"聊天会话下拉菜单和 runtime 名称展示在窄空间里更稳定",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-05-14",
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"linkify-it": "^5.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "^16.2.5",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^9.14.0",
|
||||
|
||||
@@ -24,12 +24,6 @@ function NavigationProviderInner({
|
||||
searchParams: new URLSearchParams(searchParams.toString()),
|
||||
getShareableUrl: (path: string) =>
|
||||
typeof window === "undefined" ? path : window.location.origin + path,
|
||||
// router.prefetch is a no-op in dev mode by Next.js design; in production
|
||||
// it warms the RSC payload + route chunk so the next push() commits with
|
||||
// no network round-trip. Safe to call repeatedly — Next dedupes internally.
|
||||
prefetch: (path: string) => {
|
||||
router.prefetch(path);
|
||||
},
|
||||
};
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -15,8 +15,6 @@ export const mockUser: User = {
|
||||
// field shipped — migration 054 backfills 'skipped_legacy'.
|
||||
starter_content_state: "skipped_legacy",
|
||||
language: null,
|
||||
timezone: null,
|
||||
profile_description: "",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
|
||||
#
|
||||
# Services bind to 127.0.0.1 only. For cross-machine or public access, front
|
||||
# them with a reverse proxy (Caddy / nginx / Cloudflare Tunnel) that terminates
|
||||
# TLS and forwards to 127.0.0.1:8080 (backend) and 127.0.0.1:3000 (frontend).
|
||||
# Do NOT change these bindings to 0.0.0.0 — Docker bypasses host firewalls
|
||||
# (UFW/iptables) by default, so the raw ports would be exposed to the internet
|
||||
# with the default JWT_SECRET and Postgres credentials. See:
|
||||
# apps/docs/content/docs/self-host-quickstart.mdx
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env
|
||||
# # Edit .env — change JWT_SECRET at minimum
|
||||
@@ -26,7 +18,7 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
@@ -42,7 +34,7 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:${PORT:-8080}:8080"
|
||||
- "${PORT:-8080}:8080"
|
||||
volumes:
|
||||
- backend_uploads:/app/data/uploads
|
||||
environment:
|
||||
@@ -54,11 +46,6 @@ services:
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-25}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
|
||||
@@ -76,19 +63,6 @@ services:
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
|
||||
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
|
||||
# Public URL the API is reachable at from the open internet, no
|
||||
# trailing slash. Used to mint absolute webhook URLs for autopilot
|
||||
# webhook triggers. Leave unset behind a same-origin reverse proxy
|
||||
# (e.g. plain localhost dev); the frontend will compose the URL
|
||||
# from window.origin + webhook_path in that case. Headers are
|
||||
# intentionally NOT used to derive this value, to avoid Host /
|
||||
# X-Forwarded-Host spoofing on misconfigured proxies.
|
||||
MULTICA_PUBLIC_URL: ${MULTICA_PUBLIC_URL:-}
|
||||
# Comma-separated CIDRs whose source IP is allowed to set
|
||||
# X-Forwarded-For / X-Real-IP for the webhook per-IP rate limiter.
|
||||
# Empty default = headers ignored, RemoteAddr used. Set e.g.
|
||||
# "127.0.0.1/32" when running behind a same-host reverse proxy.
|
||||
MULTICA_TRUSTED_PROXIES: ${MULTICA_TRUSTED_PROXIES:-}
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
@@ -96,7 +70,7 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ Every event is assigned to one dashboard category:
|
||||
| Category | Events |
|
||||
|---|---|
|
||||
| `core_loop` | `workspace_created`, `runtime_registered`, `runtime_ready`, `runtime_failed`, `runtime_offline`, `agent_created`, `issue_created`, `chat_message_sent`, `agent_task_queued`, `agent_task_dispatched`, `agent_task_started`, `agent_task_completed`, `agent_task_failed`, `agent_task_cancelled`, `autopilot_run_started`, `autopilot_run_completed`, `autopilot_run_failed` |
|
||||
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected` |
|
||||
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected`, `starter_content_decided` |
|
||||
| `acquisition` | `signup`, `download_intent_expressed`, `download_page_viewed`, `download_initiated`, `cloud_waitlist_joined` |
|
||||
| `ops_feedback` | `feedback_opened`, `feedback_submitted` |
|
||||
| `system/noise` | `$pageview`, `$set`, `$identify`, `$autocapture`, `$rageclick` |
|
||||
@@ -470,6 +470,21 @@ in the DB and never broadcast.
|
||||
the modal's current-workspace context and may be empty when feedback is
|
||||
sent from a pre-workspace surface.
|
||||
|
||||
### `starter_content_decided`
|
||||
|
||||
Fires on the atomic NULL → terminal state transition in both
|
||||
ImportStarterContent and DismissStarterContent. The `branch` property
|
||||
mirrors what ImportStarterContent would emit for the same workspace,
|
||||
so import-vs-dismiss rates split cleanly by branch.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `decision` | string | `imported` or `dismissed`. |
|
||||
| `branch` | string | `agent_guided` (workspace had ≥1 agent at decision time) or `self_serve` (no agents). |
|
||||
|
||||
`distinct_id` is the user's id; `workspace_id` is attached from the
|
||||
request payload.
|
||||
|
||||
### Frontend-only events
|
||||
|
||||
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
# Timezone 架构重构 — Scheduling / Viewing 两层模型
|
||||
|
||||
> Status: Implemented
|
||||
> Last updated: 2026-05-20
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **问题**:当前代码里 timezone 被三种语义混用,导致 workspace usage 页 picker 在 #2822 review 中被移除(前后端 tz 不一致会把跨 UTC 午夜的行算到错的 calendar week),同时 runtime detail 页的 timezone editor 又承担了"既是物理 tz 又是报表 tz"的双重职责。
|
||||
- **方案**:把 timezone 收敛成两个独立的 product 概念——**Scheduling**(trigger 规则里写的"9 点"是哪个 9 点,由 `autopilot_trigger.timezone` 承载)和 **Viewing**(用户报表 tz,由新字段 `user.timezone` 承载)。原先混在 `runtime.timezone` 上的"物理位置"语义(Operational)经盘查无真实消费者,整列移除。
|
||||
- **数据层**:把 `task_usage_daily` (per-runtime, 物化在 runtime tz) 和 `task_usage_dashboard_daily` (workspace 级, 物化在 UTC) **合并成一张 `task_usage_hourly` (UTC, hourly grain)**,所有报表查询按调用方 tz 在查询时切日界。
|
||||
- **新增字段**:`user.timezone`(默认 = browser detected,可在 Preferences 覆盖)。
|
||||
- **不引入** `workspace.timezone`——viewing tz 是查看者属性,不是 workspace 属性。
|
||||
- **性能**:hourly rollup 在密集工况(16 active hours/day)下单 ws 90d 窗口 ~15k 行、~15ms,和现有 daily rollup 同档。
|
||||
- **副产品**:Migration 082 的"改 runtime tz → 重灌整张 rollup"逻辑可以删除;跨 region 团队自动支持各看各的"今天";未来要做 hourly heatmap / 时段分析无需再动 schema。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
### 1.1 现状盘点
|
||||
|
||||
代码里"timezone"出现在四个地方:
|
||||
|
||||
| # | 位置 | 字段 | 实际语义 |
|
||||
|---|---|---|---|
|
||||
| 1 | `agent_runtime.timezone` | TEXT, daemon 探测或 UI 覆盖 | 报表 + 物理位置(混淆) |
|
||||
| 2 | `autopilot_trigger.timezone` | TEXT, 用户写规则时选 | Scheduling(正确) |
|
||||
| 3 | Workspace Usage 页面 | 无字段,曾在前端用 `useState(browserTimezone())` | Viewing(被 #2822 删除) |
|
||||
| 4 | 各种 list / log 时间戳显示 | 浏览器 tz | Viewing(隐式) |
|
||||
|
||||
### 1.2 问题
|
||||
|
||||
**问题 A — Runtime tz 同时承担两个不同的角色:**
|
||||
|
||||
`runtime.timezone` 在 migration 082 之后决定了 `task_usage_daily.bucket_date` 的物化口径,等于"报表 tz";同时 daemon 启动时 `detectLocalTimezone()` 写入这个字段,又当成"机器物理 tz"用。结果:
|
||||
|
||||
- 改这个字段会触发整张 rollup 重新物化(migration 082 backfill 逻辑),代价不小。
|
||||
- 一个 SF 的 dev 把 daemon 跑在 PST 的机器上,但 PM 在上海希望按 CST 出报表——这一个字段没法同时满足两个需求。
|
||||
- daemon 自动探测的"客观真值"和用户手动想换的"我想看的报表 tz"被同一个 PATCH 接口覆盖,互相打架。
|
||||
|
||||
**问题 B — Workspace usage 页面没有正确的"报表 tz"概念:**
|
||||
|
||||
PR #2822 删除了 workspace usage 页的 TimezonePicker,原因是:
|
||||
|
||||
> 后端 dashboard rollup 把数据按 UTC `bucket_date` 聚合,但前端却驱动 Weekly 边界用用户在 picker 里选的 tz。靠近 UTC 午夜的行会被放进错的 calendar week。Lock workspace Weekly to UTC and remove the timezone picker。
|
||||
|
||||
这个修复是对的——前后端 tz 不一致就是 bug。但它**没解决根本问题**:用户确实需要按自己的 tz 看 workspace 报表,只是当前数据层没法支持。
|
||||
|
||||
**问题 C — Viewing tz 没有持久化:**
|
||||
|
||||
即使 picker 还在,它也只是 `useState(browserTimezone())`——刷新页面、换设备、跨 session 都会丢。用户每次都得手动切。
|
||||
|
||||
**问题 D — 没有"跨 region 团队"的支持位:**
|
||||
|
||||
把"报表 tz"放在 workspace 上是常见的诱惑,但 workspace 里两个成员一个在 SF 一个在 Beijing,他们想看到的"今天"本来就不同。任何"workspace 级 tz 设置"都强制其中一个人看错位的报表。
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
1. **架构上清晰**:每个 timezone 字段只回答一个问题。
|
||||
2. **性能上不退步**:所有现有报表查询保持 <15ms 量级。
|
||||
3. **正确性优先**:前后端 tz 物化口径必须一致,没有"前端切了但后端没跟"的 UI 谎言。
|
||||
4. **跨 region 友好**:同一 workspace 不同成员可以各看各的"今天"。
|
||||
|
||||
---
|
||||
|
||||
## 2. 两个 timezone 概念
|
||||
|
||||
| 概念 | 在回答什么 | 谁是真值 | 承载字段 |
|
||||
|---|---|---|---|
|
||||
| **Scheduling** | "9 点跑"的 9 点是哪个 9 点 | 用户写规则那一刻的意图 | `autopilot_trigger.timezone` |
|
||||
| **Viewing** | 我想看的"今天"是哪个日历日 | 当前查看者的偏好 | `user.timezone`(新增) |
|
||||
|
||||
**关键论断**:之前代码把"物理位置"和"报表口径"混在 `runtime.timezone` 一个字段上。重构后:
|
||||
|
||||
- Scheduling 不动,`autopilot_trigger.timezone` 已经正确。
|
||||
- Viewing 由新字段 `user.timezone` 承载。
|
||||
- 数据层不再按任何固定 tz 物化 bucket,而是以 UTC 为唯一存储口径,所有报表查询在 read time 按调用方传入的 tz 切日界。
|
||||
- `runtime.timezone` 整列删除——见 §2.1。
|
||||
|
||||
### 2.1 为什么不要 Operational 层
|
||||
|
||||
最初设计有第三个概念 **Operational**(机器物理在哪)。落地盘查后砍掉,两条理由:
|
||||
|
||||
**理由一 —— 就算需要 operational tz,`runtime` 也是错的层级。** Operational tz 是**物理机器**的属性,不是 runtime 的属性。同一台机器可以跑多个 runtime,它们共用同一个 OS 时钟,operational tz 必然相同。把 tz 放在 `agent_runtime` 上,等于把一个 machine 级事实复制到同机每一行 runtime——天然的冗余与 drift 风险(同机两个 runtime 的 tz 被改得不一致是无意义的非法状态)。要建模 operational tz,正确归属是 machine 层;而当前 schema 里根本没有 machine 实体,强行放 runtime 层只是把错误固化。
|
||||
|
||||
**理由二 —— 它的消费者都不需要 operational 语义。** `runtime.timezone` 今天承担"既是物理 tz 又是报表 tz"的双重职责,但盘查后没有一个读取者真正要"机器物理 tz":
|
||||
|
||||
- runtime detail 页的 Daily / Weekly 趋势图、KPI 卡片,通过 `task_usage_daily` 的物化口径间接吃这个 tz——这是**报表口径**语义,不是 operational。而且这些成本/token 数字要和 workspace dashboard 跨页对账,dashboard 下挂多 runtime、多时区,根本不存在"workspace 的 operational tz",可对账量只能统一走 Viewing tz。
|
||||
- hour-of-day heatmap(`GetRuntimeUsageByHour` / `GetRuntimeTaskActivity`)看似要"机器作息"属性,但若只让它一个图表走 operational,用户在同一张卡里切 "Daily" ↔ "Heatmap" 会看到同一个"昨天"两个数。它也只能跟 Viewing tz。
|
||||
|
||||
autopilot 调度走 `trigger.timezone` 不碰它,daemon 要时钟直接读 OS clock,`TimezoneEditor` 只是编辑它自己。换句话说,凡是真读它的地方都应当是 Viewing tz——operational 语义在整个系统里没有一个真实需求点。
|
||||
|
||||
结论:Operational 作为服务端持久化、用户可编辑的字段没有立足点。机器有物理时钟这个**事实**永远存在,但那是 daemon 进程内部的事,不必上 server。`runtime.timezone` 整列由 migration 104 删除。
|
||||
|
||||
代价已知且接受:跨 region 团队看一台 SF runtime 的 hour-of-day heatmap 时,按查看者自己的 tz(如 Asia/Shanghai)显示活跃时段,而非机器本地的 9-to-5。对单 region 团队零影响。
|
||||
|
||||
---
|
||||
|
||||
## 3. 字段定义与 UI 文案
|
||||
|
||||
### 3.1 `runtime.timezone` — 已移除
|
||||
|
||||
由 migration `104_drop_runtime_timezone` 删除整列。daemon 注册不再上报 host tz(`detectLocalTimezone()` 删除),`PATCH /api/runtimes/:id` 不再接受 `timezone`(只剩 `visibility`),Runtime Detail 页的 timezone editor 删除。理由见 §2.1。
|
||||
|
||||
### 3.2 `autopilot_trigger.timezone` — 不动
|
||||
|
||||
已经正确。
|
||||
|
||||
### 3.3 `user.timezone` — 新增 Viewing 字段
|
||||
|
||||
实现见 migration `100_user_timezone`。表名是 `"user"`(单数、保留字需加引号):
|
||||
|
||||
```sql
|
||||
ALTER TABLE "user"
|
||||
ADD COLUMN timezone TEXT NULL;
|
||||
|
||||
COMMENT ON COLUMN "user".timezone IS
|
||||
'User-preferred IANA timezone for report rendering (Viewing tz). '
|
||||
'NULL means "use the browser-detected tz at render time". Affects '
|
||||
'dashboards, charts, and any "today" label shown to this user. Does '
|
||||
'not affect data materialisation — all rollups remain in UTC.';
|
||||
```
|
||||
|
||||
`NULL` 是默认值——前端在 NULL 时 fallback 到 `browserTimezone()`。这样新用户零配置就有合理行为。
|
||||
|
||||
UI:
|
||||
- **Settings → Preferences → Timezone**:dropdown,可选 `(browser)` 或具体 IANA name。
|
||||
- Hint:`"Used for dashboards, charts, and any 'today' label shown to you. Other users in your workspaces will see their own timezone."`
|
||||
|
||||
### 3.4 不引入 `workspace.timezone`
|
||||
|
||||
理由见 §1.2 问题 D。如果未来真有"workspace 默认报表 tz"的需求(例如新成员加入时给一个建议默认值),可以在那时再加,与本 RFC 兼容——`user.timezone` 可作为 `workspace.timezone` 的 override。
|
||||
|
||||
### 3.5 Viewing tz 如何到达后端
|
||||
|
||||
报表 handler 通过 `Handler.resolveViewingTZ(r)` 解析当前请求该用哪个 tz 渲染,优先级:
|
||||
|
||||
1. `?tz=` query param —— 浏览器端 `useViewingTimezone()` 解析后随每个报表请求显式带上。
|
||||
2. 已认证用户的 `user.timezone`(query param 缺失时的 cold fallback,会多查一次 `GetUser`)。
|
||||
3. `"UTC"` —— 兜底。
|
||||
|
||||
非法 IANA 名直接跳过该级、不报错(tz 是显示问题)。浏览器走 (1) 显式 query param 这条热路径,旧客户端 / API client 漏传时由 (2) 服务端读 `user.timezone` 兜底。Handler 拿到 tz 后用 `parseSinceParamInTZ` 把 `days=N` 折算成"查看者本地第 N 天零点"对应的 UTC 瞬间,再连同 `@tz` 一起传给 SQL。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据层设计
|
||||
|
||||
### 4.1 新表 `task_usage_hourly`
|
||||
|
||||
实现见 migration `101_task_usage_hourly_schema`(建表):
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_usage_hourly (
|
||||
bucket_hour TIMESTAMPTZ NOT NULL, -- UTC, truncated to hour boundary
|
||||
workspace_id UUID NOT NULL,
|
||||
runtime_id UUID NOT NULL,
|
||||
agent_id UUID NOT NULL,
|
||||
project_id UUID, -- nullable
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
input_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
output_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
cache_write_tokens BIGINT NOT NULL DEFAULT 0,
|
||||
task_count BIGINT NOT NULL DEFAULT 0, -- COUNT(DISTINCT task_id)
|
||||
event_count BIGINT NOT NULL DEFAULT 0, -- COUNT(*) of task_usage rows
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_task_usage_hourly_key
|
||||
UNIQUE NULLS NOT DISTINCT
|
||||
(bucket_hour, workspace_id, runtime_id, agent_id, project_id, provider, model)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_usage_hourly_workspace_time
|
||||
ON task_usage_hourly (workspace_id, bucket_hour DESC);
|
||||
CREATE INDEX idx_task_usage_hourly_runtime_time
|
||||
ON task_usage_hourly (runtime_id, bucket_hour DESC);
|
||||
CREATE INDEX idx_task_usage_hourly_workspace_agent_time
|
||||
ON task_usage_hourly (workspace_id, agent_id, bucket_hour DESC);
|
||||
CREATE INDEX idx_task_usage_hourly_workspace_project_time
|
||||
ON task_usage_hourly (workspace_id, project_id, bucket_hour DESC)
|
||||
WHERE project_id IS NOT NULL;
|
||||
```
|
||||
|
||||
**关于字段的几个落地决定**:
|
||||
|
||||
- **没有 `cost_micros` 列**。成本不在数据层物化——`task_usage_hourly` 只存 token 计数,PK 里带 `provider`+`model`,客户端按 per-model 定价表算成本。这样定价表更新无需重灌 rollup。
|
||||
- **`task_count` 与 `event_count` 两个计数**:`task_count` 是 `COUNT(DISTINCT task_id)`,`event_count` 是 `COUNT(*)`(同一 task 多次 usage 事件)。注意 task 跨多个 hour bucket 时 `task_count` 会按小时重复计——面向用户的"任务数"列优先用 `agent_task_queue` 派生的查询(见 §4.2),hourly 表的 `task_count` 仅作信息参考。
|
||||
- **`runtime_id` 为 `NOT NULL`**:`agent_task_queue.runtime_id` 本身带 `NOT NULL` 约束(migration 004),所有建队列的写入路径(含 quick-create)都会带上 runtime,所以 rollup 永远不会产生 no-runtime 的 bucket。`project_id` 可空是因为任务确实可以不挂 project。
|
||||
|
||||
migration 101 同时建了两张配套表:
|
||||
|
||||
- `task_usage_hourly_rollup_state` —— 单行 watermark 状态表(与 073/084 的 rollup_state 同形)。
|
||||
- `task_usage_hourly_dirty` —— 失效队列,承载 `updated_at` watermark 看不到的失效(`task_usage` 的 DELETE、级联 DELETE、`issue.project_id` / `agent_task_queue.runtime_id` 改动导致的重新归属)。**必须配 TTL**,见 §4.4。
|
||||
|
||||
**这一张表替换两张现有表**:
|
||||
- `task_usage_daily` (migration 073, 082) — 含 runtime_id,物化在 runtime tz
|
||||
- `task_usage_dashboard_daily` (migration 084) — 含 agent_id/project_id,物化在 UTC
|
||||
|
||||
合并后 PK 同时包含 runtime / agent / project 三个维度,可以从同一张表派生出所有现有视图。
|
||||
|
||||
### 4.2 查询模式
|
||||
|
||||
Token 类报表查询从 `task_usage_hourly` 派生,按调用方传入的 `@tz` 在查询时折算日界。**成本不在 SQL 里算**——查询只 `SUM` token 列并保留 `model` 维度,成本由客户端按 per-model 定价表折算(所以按日期分组的查询会保留 `model`,按 agent 分组的也是)。
|
||||
|
||||
```sql
|
||||
-- Workspace dashboard 趋势图 ListDashboardUsageDaily(按 viewer tz 切日,保留 model)
|
||||
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
|
||||
model,
|
||||
SUM(input_tokens)::bigint AS input_tokens,
|
||||
SUM(output_tokens)::bigint AS output_tokens,
|
||||
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
|
||||
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
|
||||
SUM(task_count)::int AS task_count
|
||||
FROM task_usage_hourly
|
||||
WHERE workspace_id = $1
|
||||
AND bucket_hour >= @since::timestamptz
|
||||
AND (@project_id::uuid IS NULL OR project_id = @project_id)
|
||||
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), model
|
||||
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, model;
|
||||
|
||||
-- Runtime detail 趋势图 ListRuntimeUsage(按 viewer tz 切日,tz 来自 user 不是 runtime)
|
||||
SELECT DATE(bucket_hour AT TIME ZONE @tz::text) AS date,
|
||||
provider, model,
|
||||
SUM(input_tokens)::bigint AS input_tokens,
|
||||
...
|
||||
FROM task_usage_hourly
|
||||
WHERE runtime_id = $1
|
||||
AND bucket_hour >= @since::timestamptz
|
||||
GROUP BY DATE(bucket_hour AT TIME ZONE @tz::text), provider, model
|
||||
ORDER BY DATE(bucket_hour AT TIME ZONE @tz::text) DESC, provider, model;
|
||||
|
||||
-- Per-agent 视图 ListDashboardUsageByAgent / ListRuntimeUsageByAgent
|
||||
-- 不按日期分组 → 不需要 @tz,只用 @since 截断(@since 已是 viewer tz 折算后的 UTC 瞬间)。
|
||||
SELECT agent_id, model,
|
||||
SUM(input_tokens)::bigint AS input_tokens,
|
||||
...
|
||||
FROM task_usage_hourly
|
||||
WHERE workspace_id = $1
|
||||
AND bucket_hour >= @since::timestamptz
|
||||
GROUP BY agent_id, model
|
||||
ORDER BY agent_id, model;
|
||||
```
|
||||
|
||||
**两类查询不走 `task_usage_hourly`**:
|
||||
|
||||
- **Time / Tasks 指标**(dashboard 的"时长 / 任务数"标签页)由独立查询 `ListDashboardRunTimeDaily` / `ListDashboardAgentRunTime` 直接打 `agent_task_queue`,按 `completed_at AT TIME ZONE @tz` 切日——任务时长来自队列的 `started_at`/`completed_at`,不是 token rollup 能表达的。它们同样吃 `@tz`,保证 Tokens/Cost/Time/Tasks 四个标签页的日界一致。
|
||||
- **Runtime hour-of-day Heatmap**(`GetRuntimeUsageByHour` / `GetRuntimeTaskActivity`)仍直接扫原始 `task_usage` / `agent_task_queue`,按 **viewer tz**(`resolveViewingTZ` 解析出的 `@tz`)做 `EXTRACT(HOUR FROM ... AT TIME ZONE @tz)`。Heatmap 窗口小(单 runtime、近 30/90d),raw 扫描足够快,没有必要从 hourly 表派生。
|
||||
|
||||
### 4.3 性能预估
|
||||
|
||||
单 workspace 90d 窗口的 `task_usage_hourly` 行数:
|
||||
|
||||
| 工况 | 行数估算 | 趋势图查询代价 |
|
||||
|---|---|---|
|
||||
| 小(5 agent × 2 model × 2 active hour × 90d) | ~1.8k | <5ms |
|
||||
| 中(5 agent × 2 model × 8 active hour × 90d) | ~7.2k | <10ms |
|
||||
| 大(5 agent × 2 model × 16 active hour × 90d) | ~14.4k | ~15ms |
|
||||
| 巨大(20 agent × 5 model × 16 active hour × 90d) | ~144k | ~50ms |
|
||||
|
||||
和现有 daily rollup 在同一档。Leaderboard / per-agent / per-project 视图同样指标。
|
||||
|
||||
### 4.4 Rollup worker 改造
|
||||
|
||||
现有两张 rollup 表的写入逻辑合并成一条管线,实现见 migration `102_task_usage_hourly_pipeline`(触发器 + 窗口函数 + 失效队列 TTL + pg_cron 调度):
|
||||
|
||||
- 源数据扫描不变(仍然扫 `task_usage` 增量 + 失效队列)。`bucket_hour` 用 `task_usage_hour_bucket(tu.created_at)`(UTC 整点截断)。
|
||||
- Upsert 目标从两张 daily 表改为一张 `task_usage_hourly`。
|
||||
- 失效队列维度由 `(bucket_date, …)` 改为 `(bucket_hour, …)`(`task_usage_hourly_dirty`),由 `task_usage` / `agent_task_queue` / `issue` 上的触发器写入。**必须配 TTL(保留 7 天)**,否则脏行在密集工况下无界增长——这是整个设计最容易漏的正确性要求(hourly 粒度把脏面比 daily 放大了 ~24×)。
|
||||
- 调度入口 `rollup_task_usage_hourly()` 由 pg_cron 周期触发:取 advisory lock → 从 `task_usage_hourly_rollup_state` 读 watermark → 调 `rollup_task_usage_hourly_window(from, to)` 重算脏 bucket → 推进 watermark → 释放锁后跑 `prune_task_usage_hourly_dirty()`。单 tick 窗口上限 1 天,watermark 落后时分多次 tick 追平,不会一条语句锁表重算多周。
|
||||
|
||||
源表扫描是 worker 的主要开销,目标表换粒度只让单 tick 多几十 ms upsert,不会成倍增长。
|
||||
|
||||
### 4.5 Migration 082 的副作用消除
|
||||
|
||||
当前 `runtime.timezone` 的 PATCH 处理(migration 082 + 现有 handler)会触发该 runtime 的整张 `task_usage_daily` 重新物化——因为 `bucket_date` 含了 tz。
|
||||
|
||||
新方案下 `bucket_hour` 永远是 UTC,**`runtime.timezone` 改变不再触发任何数据层操作**。改 tz 立即生效,零 backfill。这同时修掉了:
|
||||
|
||||
- 改 tz 期间的 race condition(旧 bucket 还没重灌完,新查询已经按新 tz 渲染)。
|
||||
- daemon 第一次注册时探测到非 UTC 的 tz 但历史 rollup 还是 UTC 的尴尬过渡期。
|
||||
|
||||
---
|
||||
|
||||
## 5. UI / UX 影响
|
||||
|
||||
### 5.1 Runtime Detail 页
|
||||
|
||||
| 组件 | 重构前 tz 来源 | 重构后 tz 来源 |
|
||||
|---|---|---|
|
||||
| Daily / Weekly 趋势图 | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
|
||||
| KPI 卡片 | `runtime.timezone`(隐式) | `user.timezone ?? browserTimezone()` |
|
||||
| 日历活跃热力图 | `runtime.timezone` 锚点 + viewer-tz 数据(不一致 bug) | `user.timezone ?? browserTimezone()`(锚点与数据统一) |
|
||||
| Hour-of-day Heatmap | `runtime.timezone` | `user.timezone ?? browserTimezone()` |
|
||||
| Timezone editor | 写 `runtime.timezone` | **删除** |
|
||||
|
||||
**用户可感知的行为变化**:
|
||||
|
||||
- Runtime Detail 页所有图表统一跟随 viewer 自己的 tz;页面上不再有任何 runtime 级 tz 控件。
|
||||
- 想换报表 tz 的用户去 Settings → Preferences 改一次,所有 workspace / runtime 的报表立刻全跟着变。
|
||||
- 跨 region 团队:hour-of-day heatmap 按查看者 tz 显示活跃时段(已知且接受的取舍,见 §2.1)。
|
||||
|
||||
### 5.2 Workspace Usage 页
|
||||
|
||||
恢复"按 viewing tz 渲染"的能力,但**不放页面级 picker**。理由:
|
||||
|
||||
- Picker 当年被加上去就是因为没有持久化的 viewing tz 概念。现在有了 `user.timezone`,picker 的诉求被 Preferences 替代。
|
||||
- 页面级 picker 容易让用户误以为"这是一个 view-state",但 viewing tz 是全应用属性,不是单页设置。
|
||||
- 减少 UI 控件 = 减少认知负担。
|
||||
|
||||
`packages/views/dashboard/components/dashboard-page.tsx` 里的 `WEEK_TZ = "UTC"` 改成 `useViewingTimezone()`(hook 见 `packages/views/common/use-viewing-timezone.ts`),相应的解释性注释删除。
|
||||
|
||||
### 5.3 Preferences 页
|
||||
|
||||
新增一个 Timezone setting,和现有的语言 / 主题等并列。
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施
|
||||
|
||||
> 产品尚未上线,无存量用户需保护,全部变更作为一组迁移一次性交付——旧的 daily 管线在同一分支里直接拆除,不保留共存期。
|
||||
|
||||
整套变更落在分支 `feat/timezone-architecture`,migration 100–104:
|
||||
|
||||
| Migration | 内容 |
|
||||
|---|---|
|
||||
| `100_user_timezone` | 加 `"user".timezone` 列(nullable) |
|
||||
| `101_task_usage_hourly_schema` | 建 `task_usage_hourly` + `task_usage_hourly_rollup_state` + `task_usage_hourly_dirty` + 索引 |
|
||||
| `102_task_usage_hourly_pipeline` | 失效触发器、`rollup_task_usage_hourly_window` 窗口函数、`prune_task_usage_hourly_dirty()` 失效队列 TTL、带单日 cap 与 prune 的 `rollup_task_usage_hourly()` cron 入口、pg_cron 调度 |
|
||||
| `103_drop_legacy_daily_rollups` | 拆掉 `task_usage_daily` / `task_usage_dashboard_daily` 两条旧管线(表、函数、触发器、pg_cron 任务) |
|
||||
| `104_drop_runtime_timezone` | 删除 `agent_runtime.timezone` 列(Operational 层移除,见 §2.1) |
|
||||
|
||||
配套的代码侧改动:
|
||||
|
||||
- **数据回填**:一次性命令 `cmd/backfill_task_usage_hourly`,按 workspace 切片把历史 `task_usage` 灌进新表。旧的 `cmd/backfill_task_usage_daily` / `cmd/backfill_task_usage_dashboard_daily` 已删除。
|
||||
- **查询切换**:后端所有报表查询迁到 `task_usage_hourly`(或 Time/Tasks 的 `agent_task_queue` 查询),统一接受 `@tz`;`UseDailyRollupForDashboard` / `UseDailyRollupForRuntimeUsage` 等 feature flag 与旧的 raw-scan / daily-rollup 双查询路径一并删除。
|
||||
- **前端打通**:`useViewingTimezone()` hook 解析 viewer tz,报表组件随请求带 `?tz=`;`dashboard-page.tsx` 的 `WEEK_TZ = "UTC"` 改为 `useViewingTimezone()`,原 UTC-lock 解释性注释删除。
|
||||
- **UI 文案**:Preferences 新增 Timezone setting。Runtime Detail 页的 timezone editor 整体删除。
|
||||
- **runtime tz 移除**:`PATCH /api/runtimes/:id` 的 `timezone` 字段删除,该端点只剩 `visibility`;daemon 注册不再上报 host tz;`agent_runtime.timezone` 列由 migration 104 删除。
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions / Risks
|
||||
|
||||
### 7.1 Risks
|
||||
|
||||
- **Invalidation queue TTL 是必做**。如果忘记加,密集工况下 queue 会无界增长。
|
||||
- **Hourly rollup backfill 期间的源表 read pressure**。按 workspace 切片、低峰期跑,预期 OK,但需要提前给 DB 团队打招呼。
|
||||
- **DST 当天的 23h/25h "日"**。`DATE(bucket_hour AT TIME ZONE @tz)` 会正确处理,但前端任何"一天 = 24 小时"的硬编码偏移逻辑要测一遍 DST 边界。
|
||||
- **现有 `runtime.timezone` 的 PATCH endpoint 行为变了**。改完不再触发 backfill——这是好事,但 API 文档和 changelog 要写清楚,避免下游集成误判。
|
||||
|
||||
### 7.2 Open question
|
||||
|
||||
- **Trigger 的 timezone 默认值**?目前用户必须手动选;可以默认 `user.timezone`,但用户写 trigger 时的 viewing tz 和 trigger 实际跑的 tz 是两件事,需要产品决策。
|
||||
|
||||
### 7.3 非目标
|
||||
|
||||
- **不做** workspace 级 tz 设置:跨 region 团队两个成员各自正确的"今天"不同,workspace 级 tz 必让其中一方看错位报表。
|
||||
- **不做** 预物化多 tz rollup:IANA tz 列表有 ~600 个无法穷举、DST 需逐 tz 维护,而 hourly rollup 已经够快。
|
||||
- **不做** issue / comment / inbox 等列表的 tz 切换——它们已经隐式用浏览器 tz,本 RFC 不动。后续如果要让这些也跟 `user.timezone`,是独立的 follow-up。
|
||||
|
||||
---
|
||||
|
||||
## 8. 决策汇总
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|---|---|
|
||||
| Timezone 概念分层 | Scheduling / Viewing 两层(Operational 经盘查后移除) |
|
||||
| `runtime.timezone` 角色 | ❌ 整列删除(migration 104) |
|
||||
| `user.timezone` 是否新增 | ✅ 新增,nullable,默认 fallback 到 browser |
|
||||
| `workspace.timezone` 是否新增 | ❌ 不引入 |
|
||||
| 数据层物化口径 | 统一 UTC, hourly grain |
|
||||
| Rollup 表合并 | `task_usage_daily` + `task_usage_dashboard_daily` → `task_usage_hourly` |
|
||||
| 报表 tz 切换粒度 | 全局 per-user(Preferences),不做 per-view picker |
|
||||
| hour-of-day heatmap tz | viewer tz(不再用机器物理 tz) |
|
||||
@@ -1,111 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { TestApiClient } from "./fixtures";
|
||||
|
||||
// Smoke test for Onboarding V2: verifies the new per-question flow
|
||||
// renders and captures screenshots for review. Uses a unique email
|
||||
// per run so the user is always a fresh, un-onboarded user landing
|
||||
// on /onboarding.
|
||||
|
||||
const EMAIL = `onboarding-v2-${Date.now()}@localhost`;
|
||||
const SHOTS_DIR = "/tmp/onboarding-v2-shots";
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test("onboarding v2 — welcome → source → role → use_case (skip path)", async ({ page }) => {
|
||||
const api = new TestApiClient();
|
||||
await api.login(EMAIL, "OBv2 Tester");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
}, token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// 1. Welcome screen
|
||||
await expect(page.getByRole("button", { name: "Continue on web" })).toBeVisible({ timeout: 15000 });
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/01-welcome.png`, fullPage: false });
|
||||
|
||||
// Click Start exploring to advance to Source
|
||||
await page.getByRole("button", { name: "Continue on web" }).click();
|
||||
|
||||
// 2. Source step
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
|
||||
|
||||
// Pick Friends/colleagues then click Continue to advance.
|
||||
await page.getByRole("radio", { name: /Friends or colleagues/i }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// 3. Role step
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
|
||||
|
||||
// Skip role
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
// 4. Use case step
|
||||
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
|
||||
|
||||
// Pick ship_code then Continue → workspace step.
|
||||
await page.getByRole("radio", { name: /Ship code with AI agents/i }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// 5. Workspace step (legacy)
|
||||
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/05-workspace.png` });
|
||||
});
|
||||
|
||||
test("onboarding v2 — rage-skip all 3 questions", async ({ page }) => {
|
||||
const api = new TestApiClient();
|
||||
await api.login(`rage-skip-${Date.now()}@localhost`, "Rage Skipper");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("button", { name: "Continue on web" }).click();
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Skip × 3
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
// Lands on workspace step
|
||||
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/06-after-rage-skip.png` });
|
||||
});
|
||||
|
||||
test("onboarding v2 — zh-Hans renders Chinese labels", async ({ page, context }) => {
|
||||
await context.addCookies([
|
||||
{ name: "multica-locale", value: "zh-Hans", url: "http://localhost:13442" },
|
||||
]);
|
||||
const api = new TestApiClient();
|
||||
await api.login(`zh-${Date.now()}@localhost`, "中文用户");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("button").first().click().catch(() => {});
|
||||
|
||||
// Source screen — Chinese question
|
||||
await expect(page.getByText("你是从哪里了解到 Multica 的?")).toBeVisible({ timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/07-source-zh.png` });
|
||||
});
|
||||
@@ -50,6 +50,7 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -3,7 +3,3 @@ export {
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
} from "./view-store";
|
||||
export {
|
||||
useTranscriptViewStore,
|
||||
type TranscriptSortDirection,
|
||||
} from "./transcript-view-store";
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useTranscriptViewStore } from "./transcript-view-store";
|
||||
|
||||
beforeEach(() => {
|
||||
useTranscriptViewStore.setState({ sortDirection: "chronological" });
|
||||
});
|
||||
|
||||
describe("useTranscriptViewStore", () => {
|
||||
it("defaults to chronological so existing readers see no behavior change", () => {
|
||||
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
|
||||
});
|
||||
|
||||
it("setSortDirection switches between the two known directions", () => {
|
||||
const { setSortDirection } = useTranscriptViewStore.getState();
|
||||
|
||||
setSortDirection("newest_first");
|
||||
expect(useTranscriptViewStore.getState().sortDirection).toBe("newest_first");
|
||||
|
||||
setSortDirection("chronological");
|
||||
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type TranscriptSortDirection = "chronological" | "newest_first";
|
||||
|
||||
interface TranscriptViewState {
|
||||
sortDirection: TranscriptSortDirection;
|
||||
setSortDirection: (dir: TranscriptSortDirection) => void;
|
||||
}
|
||||
|
||||
export const useTranscriptViewStore = create<TranscriptViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sortDirection: "chronological",
|
||||
setSortDirection: (sortDirection) => set({ sortDirection }),
|
||||
}),
|
||||
{
|
||||
name: "multica_transcript_view",
|
||||
storage: createJSONStorage(() => defaultStorage),
|
||||
partialize: (state) => ({ sortDirection: state.sortDirection }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -48,11 +48,10 @@ describe("ApiClient", () => {
|
||||
await client.getAutopilot("ap-1");
|
||||
await client.createAutopilot({
|
||||
title: "Daily triage",
|
||||
project_id: "project-1",
|
||||
assignee_id: "agent-1",
|
||||
execution_mode: "create_issue",
|
||||
});
|
||||
await client.updateAutopilot("ap-1", { status: "paused", project_id: null });
|
||||
await client.updateAutopilot("ap-1", { status: "paused" });
|
||||
await client.deleteAutopilot("ap-1");
|
||||
await client.triggerAutopilot("ap-1");
|
||||
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
|
||||
@@ -63,7 +62,6 @@ describe("ApiClient", () => {
|
||||
});
|
||||
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
|
||||
await client.deleteAutopilotTrigger("ap-1", "tr-1");
|
||||
await client.rotateAutopilotTriggerWebhookToken("ap-1", "tr-1");
|
||||
|
||||
const calls = fetchMock.mock.calls.map(([url, init]) => ({
|
||||
url,
|
||||
@@ -79,7 +77,6 @@ describe("ApiClient", () => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: "Daily triage",
|
||||
project_id: "project-1",
|
||||
assignee_id: "agent-1",
|
||||
execution_mode: "create_issue",
|
||||
}),
|
||||
@@ -87,7 +84,7 @@ describe("ApiClient", () => {
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1",
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status: "paused", project_id: null }),
|
||||
body: JSON.stringify({ status: "paused" }),
|
||||
},
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/trigger", method: "POST" },
|
||||
@@ -107,10 +104,6 @@ describe("ApiClient", () => {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
},
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1/rotate-webhook-token",
|
||||
method: "POST",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -152,91 +145,6 @@ describe("ApiClient", () => {
|
||||
expect(headers["X-Client-OS"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the Cloud Runtime node API contract and forwards bootstrap PAT on create", async () => {
|
||||
const node = {
|
||||
id: "node-1",
|
||||
owner_id: "user-1",
|
||||
instance_id: "i-0123456789abcdef0",
|
||||
region: "us-west-2",
|
||||
instance_type: "g5.xlarge",
|
||||
image_id: "ami-1",
|
||||
subnet_id: "subnet-1",
|
||||
name: "gpu-dev-01",
|
||||
status: "launching",
|
||||
tags: {},
|
||||
metadata: {},
|
||||
created_at: "2026-05-21T08:30:00Z",
|
||||
updated_at: "2026-05-21T08:30:00Z",
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(node), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.listCloudRuntimeNodes({ limit: 20, offset: 5 });
|
||||
await client.createCloudRuntimeNode(
|
||||
{ instance_type: "g5.xlarge", name: "gpu-dev-01" },
|
||||
{ userPAT: "mul_cloud_bootstrap_pat" },
|
||||
);
|
||||
|
||||
const listCall = fetchMock.mock.calls[0]!;
|
||||
const createCall = fetchMock.mock.calls[1]!;
|
||||
expect(listCall[0]).toBe(
|
||||
"https://api.example.test/api/cloud-runtime/nodes?limit=20&offset=5",
|
||||
);
|
||||
expect((listCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBeUndefined();
|
||||
expect(createCall[0]).toBe(
|
||||
"https://api.example.test/api/cloud-runtime/nodes",
|
||||
);
|
||||
expect(createCall[1]).toMatchObject({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
instance_type: "g5.xlarge",
|
||||
name: "gpu-dev-01",
|
||||
}),
|
||||
});
|
||||
expect((createCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBe(
|
||||
"mul_cloud_bootstrap_pat",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when Cloud Runtime node responses drift", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ id: 123 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ id: 123 }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
|
||||
await expect(client.listCloudRuntimeNodes()).resolves.toEqual([]);
|
||||
await expect(
|
||||
client.createCloudRuntimeNode({ instance_type: "g5.xlarge" }),
|
||||
).resolves.toMatchObject({ id: "", status: "" });
|
||||
});
|
||||
|
||||
describe("getAttachment", () => {
|
||||
it("returns the parsed attachment for a well-formed response", async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
Issue,
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
GroupedIssuesResponse,
|
||||
ListIssuesResponse,
|
||||
SearchIssuesResponse,
|
||||
SearchProjectsResponse,
|
||||
@@ -10,7 +9,6 @@ import type {
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
ListIssuesParams,
|
||||
ListGroupedIssuesParams,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
AgentTemplate,
|
||||
@@ -47,7 +45,6 @@ import type {
|
||||
DashboardUsageDaily,
|
||||
DashboardUsageByAgent,
|
||||
DashboardAgentRunTime,
|
||||
DashboardRunTimeDaily,
|
||||
RuntimeUpdate,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeLocalSkillListRequest,
|
||||
@@ -89,8 +86,6 @@ import type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
WebhookDelivery,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
GitHubPullRequest,
|
||||
@@ -98,15 +93,8 @@ import type {
|
||||
GitHubConnectResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import type {
|
||||
CloudRuntimeNode,
|
||||
CreateCloudRuntimeNodeOptions,
|
||||
CreateCloudRuntimeNodeRequest,
|
||||
ListCloudRuntimeNodesParams,
|
||||
} from "../runtimes/cloud-runtime";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
@@ -117,40 +105,19 @@ import {
|
||||
AttachmentResponseSchema,
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
CloudRuntimeNodeListSchema,
|
||||
CloudRuntimeNodeSchema,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardRunTimeDailyListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||||
EMPTY_ATTACHMENT,
|
||||
EMPTY_CLOUD_RUNTIME_NODE,
|
||||
EMPTY_CLOUD_RUNTIME_NODE_LIST,
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
EMPTY_GROUPED_ISSUES_RESPONSE,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_SQUAD_MEMBER_STATUS_LIST,
|
||||
EMPTY_TIMELINE_ENTRIES,
|
||||
EMPTY_USER,
|
||||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||||
EMPTY_WEBHOOK_DELIVERY,
|
||||
GroupedIssuesResponseSchema,
|
||||
ListIssuesResponseSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
RuntimeUsageListSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
UserSchema,
|
||||
WebhookDeliveryResponseSchema,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
@@ -178,29 +145,51 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface OnboardingRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
// --- Starter content (post-onboarding import) -----------------------------
|
||||
// Shape mirrors the Go request/response in handler/onboarding.go.
|
||||
//
|
||||
// The client sends both branches of sub-issues and an unbound welcome
|
||||
// issue template (title + description, no `agent_id`). The SERVER picks
|
||||
// the branch by inspecting the workspace's agent list inside the
|
||||
// import transaction. This removes the client as a trusted decider —
|
||||
// even if the client has a stale agent cache or lies, the server uses
|
||||
// the DB as source of truth.
|
||||
|
||||
export interface ImportStarterIssuePayload {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
/** Server uses `user_id` (per app-wide AssigneePicker convention)
|
||||
* as assignee when true. No member_id is threaded through. */
|
||||
assign_to_self: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
agent_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
export interface OnboardingNoRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
issue_id: string;
|
||||
export interface ImportStarterWelcomeIssueTemplate {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Defaults to "high" on server when empty. */
|
||||
priority: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingNoRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
export interface ImportStarterContentPayload {
|
||||
workspace_id: string;
|
||||
project: { title: string; description: string; icon: string };
|
||||
/** Always sent. Server creates it only when an agent exists in the
|
||||
* workspace; ignored otherwise. Agent id is picked by the server. */
|
||||
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
|
||||
/** Used when the workspace has at least one agent. */
|
||||
agent_guided_sub_issues: ImportStarterIssuePayload[];
|
||||
/** Used when the workspace has zero agents. */
|
||||
self_serve_sub_issues: ImportStarterIssuePayload[];
|
||||
}
|
||||
|
||||
export interface ImportStarterContentResponse {
|
||||
user: User;
|
||||
project_id: string;
|
||||
/** Non-null when server took the agent-guided branch. */
|
||||
welcome_issue_id: string | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -397,95 +386,71 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me");
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "GET /api/me",
|
||||
});
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
|
||||
async markOnboardingComplete(payload?: {
|
||||
completion_path?: OnboardingCompletionPath;
|
||||
workspace_id?: string;
|
||||
}): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me/onboarding/complete", {
|
||||
return this.fetch("/api/me/onboarding/complete", {
|
||||
method: "POST",
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "POST /api/me/onboarding/complete",
|
||||
});
|
||||
}
|
||||
|
||||
async bootstrapOnboardingRuntime(payload: {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
}): Promise<OnboardingRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async bootstrapOnboardingNoRuntime(payload: {
|
||||
workspace_id: string;
|
||||
}): Promise<OnboardingNoRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/no-runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async joinCloudWaitlist(payload: {
|
||||
email: string;
|
||||
reason?: string;
|
||||
}): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me/onboarding/cloud-waitlist", {
|
||||
return this.fetch("/api/me/onboarding/cloud-waitlist", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "POST /api/me/onboarding/cloud-waitlist",
|
||||
});
|
||||
}
|
||||
|
||||
async patchOnboarding(payload: {
|
||||
questionnaire?: Record<string, unknown>;
|
||||
}): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me/onboarding", {
|
||||
return this.fetch("/api/me/onboarding", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "PATCH /api/me/onboarding",
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the Getting Started project + optional welcome issue + sub-issues
|
||||
* in a single server-side transaction. Gated by an atomic
|
||||
* starter_content_state: NULL → 'imported' claim — a second call returns
|
||||
* 409 (already decided) and creates nothing new.
|
||||
*
|
||||
* The content templates live in TypeScript (see
|
||||
* @multica/views/onboarding/utils/starter-content-templates) and are
|
||||
* rendered from the user's questionnaire answers before being sent.
|
||||
*/
|
||||
async importStarterContent(
|
||||
payload: ImportStarterContentPayload,
|
||||
): Promise<ImportStarterContentResponse> {
|
||||
return this.fetch("/api/me/starter-content/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async dismissStarterContent(payload?: {
|
||||
workspace_id?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/starter-content/dismiss", {
|
||||
method: "POST",
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async updateMe(data: UpdateMeRequest): Promise<User> {
|
||||
const raw = await this.fetch<unknown>("/api/me", {
|
||||
return this.fetch("/api/me", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||||
endpoint: "PATCH /api/me",
|
||||
});
|
||||
}
|
||||
|
||||
// Issues
|
||||
@@ -500,9 +465,7 @@ export class ApiClient {
|
||||
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params?.project_id) search.set("project_id", params.project_id);
|
||||
if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id);
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
if (params?.scheduled) search.set("scheduled", "true");
|
||||
const path = `/api/issues?${search}`;
|
||||
const raw = await this.fetch<unknown>(path);
|
||||
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
||||
@@ -510,37 +473,6 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listGroupedIssues(params: ListGroupedIssuesParams): Promise<GroupedIssuesResponse> {
|
||||
const search = new URLSearchParams({ group_by: params.group_by });
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
if (params.offset) search.set("offset", String(params.offset));
|
||||
if (params.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
if (params.statuses?.length) search.set("statuses", params.statuses.join(","));
|
||||
if (params.priorities?.length) search.set("priorities", params.priorities.join(","));
|
||||
if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(","));
|
||||
if (params.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id);
|
||||
if (params.assignee_filters?.length) {
|
||||
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||||
}
|
||||
if (params.include_no_assignee) search.set("include_no_assignee", "true");
|
||||
if (params.creator_filters?.length) {
|
||||
search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||||
}
|
||||
if (params.project_ids?.length) search.set("project_ids", params.project_ids.join(","));
|
||||
if (params.include_no_project) search.set("include_no_project", "true");
|
||||
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
|
||||
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
|
||||
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
|
||||
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
|
||||
return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, {
|
||||
endpoint: "GET /api/issues/grouped",
|
||||
});
|
||||
}
|
||||
|
||||
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
|
||||
const search = new URLSearchParams({ q: params.q });
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
@@ -829,54 +761,13 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes?${search}`);
|
||||
}
|
||||
|
||||
async listCloudRuntimeNodes(
|
||||
params?: ListCloudRuntimeNodesParams,
|
||||
): Promise<CloudRuntimeNode[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params?.offset !== undefined) search.set("offset", String(params.offset));
|
||||
const query = search.toString();
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/cloud-runtime/nodes${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CloudRuntimeNodeListSchema,
|
||||
EMPTY_CLOUD_RUNTIME_NODE_LIST,
|
||||
{ endpoint: "GET /api/cloud-runtime/nodes" },
|
||||
);
|
||||
}
|
||||
|
||||
async createCloudRuntimeNode(
|
||||
data: CreateCloudRuntimeNodeRequest,
|
||||
options?: CreateCloudRuntimeNodeOptions,
|
||||
): Promise<CloudRuntimeNode> {
|
||||
const extraHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const userPAT = options?.userPAT?.trim();
|
||||
if (userPAT) extraHeaders["X-User-PAT"] = userPAT;
|
||||
const res = await this.fetchRaw("/api/cloud-runtime/nodes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
extraHeaders,
|
||||
});
|
||||
const raw = await res.json() as unknown;
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CloudRuntimeNodeSchema,
|
||||
EMPTY_CLOUD_RUNTIME_NODE,
|
||||
{ endpoint: "POST /api/cloud-runtime/nodes" },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRuntime(runtimeId: string): Promise<void> {
|
||||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateRuntime(
|
||||
runtimeId: string,
|
||||
patch: { visibility?: "private" | "public" },
|
||||
patch: { timezone?: string; visibility?: "private" | "public" },
|
||||
): Promise<AgentRuntime> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||||
method: "PATCH",
|
||||
@@ -884,77 +775,32 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getRuntimeUsage(
|
||||
runtimeId: string,
|
||||
params?: { days?: number; tz?: string },
|
||||
): Promise<RuntimeUsage[]> {
|
||||
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
// `tz` drives the calendar-day boundary for the trend chart (Viewing
|
||||
// layer). Caller-supplied; the backend falls back to user.timezone /
|
||||
// UTC if omitted.
|
||||
if (params?.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/runtimes/${runtimeId}/usage?${search}`,
|
||||
);
|
||||
return parseWithFallback<RuntimeUsage[]>(raw, RuntimeUsageListSchema, [], {
|
||||
endpoint: "GET /api/runtimes/:id/usage",
|
||||
});
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
|
||||
}
|
||||
|
||||
async getRuntimeTaskActivity(
|
||||
runtimeId: string,
|
||||
params?: { tz?: string },
|
||||
): Promise<RuntimeHourlyActivity[]> {
|
||||
// Hour-of-day heatmap follows the viewer's tz, like the other reports on
|
||||
// this page. Pass the viewer's IANA zone so the server buckets correctly.
|
||||
const search = new URLSearchParams();
|
||||
if (params?.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/runtimes/${runtimeId}/activity?${search}`,
|
||||
);
|
||||
return parseWithFallback<RuntimeHourlyActivity[]>(
|
||||
raw,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/runtimes/:id/activity" },
|
||||
);
|
||||
async getRuntimeTaskActivity(runtimeId: string): Promise<RuntimeHourlyActivity[]> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
|
||||
}
|
||||
|
||||
async getRuntimeUsageByAgent(
|
||||
runtimeId: string,
|
||||
params?: { days?: number; tz?: string },
|
||||
params?: { days?: number },
|
||||
): Promise<RuntimeUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
if (params?.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/runtimes/${runtimeId}/usage/by-agent?${search}`,
|
||||
);
|
||||
return parseWithFallback<RuntimeUsageByAgent[]>(
|
||||
raw,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/runtimes/:id/usage/by-agent" },
|
||||
);
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
|
||||
}
|
||||
|
||||
async getRuntimeUsageByHour(
|
||||
runtimeId: string,
|
||||
params?: { days?: number; tz?: string },
|
||||
params?: { days?: number },
|
||||
): Promise<RuntimeUsageByHour[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
if (params?.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/runtimes/${runtimeId}/usage/by-hour?${search}`,
|
||||
);
|
||||
return parseWithFallback<RuntimeUsageByHour[]>(
|
||||
raw,
|
||||
RuntimeUsageByHourListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/runtimes/:id/usage/by-hour" },
|
||||
);
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -965,12 +811,11 @@ export class ApiClient {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getDashboardUsageDaily(
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardUsageDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
|
||||
return parseWithFallback<DashboardUsageDaily[]>(
|
||||
raw,
|
||||
@@ -981,12 +826,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardUsageByAgent(
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
|
||||
return parseWithFallback<DashboardUsageByAgent[]>(
|
||||
raw,
|
||||
@@ -997,14 +841,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardAgentRunTime(
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardAgentRunTime[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
// `tz` aligns the "last N days" cutoff with the viewer's calendar,
|
||||
// matching the per-agent token card.
|
||||
if (params.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
|
||||
return parseWithFallback<DashboardAgentRunTime[]>(
|
||||
raw,
|
||||
@@ -1014,24 +855,6 @@ export class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardRunTimeDaily(
|
||||
params: { days?: number; project_id?: string | null; tz?: string },
|
||||
): Promise<DashboardRunTimeDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
// `tz` cuts the day buckets in the viewer's calendar so Time / Tasks
|
||||
// align with the Cost / Tokens charts.
|
||||
if (params.tz) search.set("tz", params.tz);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
|
||||
return parseWithFallback<DashboardRunTimeDaily[]>(
|
||||
raw,
|
||||
DashboardRunTimeDailyListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/dashboard/runtime/daily" },
|
||||
);
|
||||
}
|
||||
|
||||
async initiateUpdate(
|
||||
runtimeId: string,
|
||||
targetVersion: string,
|
||||
@@ -1140,10 +963,9 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async rerunIssue(issueId: string, taskId?: string): Promise<AgentTask> {
|
||||
async rerunIssue(issueId: string): Promise<AgentTask> {
|
||||
return this.fetch(`/api/issues/${issueId}/rerun`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(taskId ? { task_id: taskId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1220,7 +1042,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string }): Promise<Workspace> {
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
|
||||
return this.fetch(`/api/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
@@ -1637,7 +1459,7 @@ export class ApiClient {
|
||||
return this.fetch(`/api/squads/${id}`);
|
||||
}
|
||||
|
||||
async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise<Squad> {
|
||||
async createSquad(data: { name: string; description?: string; leader_id: string }): Promise<Squad> {
|
||||
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
@@ -1665,17 +1487,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
// Per-squad members status snapshot: one row per member with derived
|
||||
// working/idle/offline/unstable plus the issues each agent is currently
|
||||
// running. Parsed with a lenient schema so a new server-side status
|
||||
// value or extra field can't white-screen the Squad page (#2143).
|
||||
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
|
||||
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
|
||||
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
|
||||
endpoint: "GET /api/squads/:id/members/status",
|
||||
}) as SquadMemberStatusListResponse;
|
||||
}
|
||||
|
||||
// Autopilots
|
||||
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
@@ -1716,13 +1527,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
|
||||
}
|
||||
|
||||
// Returns a single run including its full trigger_payload. List responses
|
||||
// omit trigger_payload to keep them small (a webhook envelope can be
|
||||
// up to 256 KiB × limit rows), so the detail view fetches via this route.
|
||||
async getAutopilotRun(autopilotId: string, runId: string): Promise<AutopilotRun> {
|
||||
return this.fetch(`/api/autopilots/${autopilotId}/runs/${runId}`);
|
||||
}
|
||||
|
||||
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
|
||||
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
|
||||
method: "POST",
|
||||
@@ -1741,74 +1545,6 @@ export class ApiClient {
|
||||
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async rotateAutopilotTriggerWebhookToken(
|
||||
autopilotId: string,
|
||||
triggerId: string,
|
||||
): Promise<AutopilotTrigger> {
|
||||
return this.fetch(
|
||||
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
// Webhook deliveries — list is slim (no raw_body / selected_headers /
|
||||
// response_body); detail returns the full row. Both responses are parsed
|
||||
// through a lenient schema so an unknown server-side `status` /
|
||||
// `signature_status` value degrades to a generic row instead of dropping
|
||||
// the whole list.
|
||||
async listAutopilotDeliveries(
|
||||
autopilotId: string,
|
||||
params?: { limit?: number; offset?: number },
|
||||
): Promise<ListWebhookDeliveriesResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", params.limit.toString());
|
||||
if (params?.offset) search.set("offset", params.offset.toString());
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/autopilots/${autopilotId}/deliveries?${search}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||||
{ endpoint: "GET /api/autopilots/:id/deliveries" },
|
||||
);
|
||||
}
|
||||
|
||||
async getAutopilotDelivery(
|
||||
autopilotId: string,
|
||||
deliveryId: string,
|
||||
): Promise<WebhookDelivery> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
WebhookDeliveryResponseSchema,
|
||||
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
|
||||
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
|
||||
);
|
||||
}
|
||||
|
||||
// Replay creates a NEW delivery row referencing the original via
|
||||
// `replayed_from_delivery_id`. Server rejects replays of
|
||||
// signature-invalid / rejected deliveries with 400 — the UI keeps the
|
||||
// button disabled for those rows, but the server is the source of truth.
|
||||
async replayAutopilotDelivery(
|
||||
autopilotId: string,
|
||||
deliveryId: string,
|
||||
): Promise<WebhookDelivery> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
WebhookDeliveryResponseSchema,
|
||||
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
|
||||
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub integration
|
||||
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
|
||||
|
||||
@@ -4,11 +4,15 @@ export {
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "./client";
|
||||
export type { ApiClientOptions } from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
ImportStarterContentResponse,
|
||||
ImportStarterIssuePayload,
|
||||
ImportStarterWelcomeIssueTemplate,
|
||||
} from "./client";
|
||||
export { parseWithFallback, setSchemaLogger } from "./schema";
|
||||
export type { ParseOptions } from "./schema";
|
||||
export { DuplicateIssueErrorBodySchema } from "./schemas";
|
||||
export type { DuplicateIssueErrorBody } from "./schemas";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
import type { ApiClient as ApiClientType } from "./client";
|
||||
|
||||
@@ -91,15 +91,6 @@ describe("ApiClient schema fallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listGroupedIssues", () => {
|
||||
it("falls back to empty groups when the response is malformed", async () => {
|
||||
stubFetchJson({ groups: "not-an-array" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listGroupedIssues({ group_by: "assignee" });
|
||||
expect(res).toEqual({ groups: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listComments", () => {
|
||||
it("returns [] when the response is not an array", async () => {
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
@@ -198,68 +189,6 @@ describe("ApiClient schema fallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAutopilotDeliveries", () => {
|
||||
it("falls back to an empty list when the body is null", async () => {
|
||||
stubFetchJson(null);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilotDeliveries("ap-1");
|
||||
expect(res).toEqual({ deliveries: [], total: 0 });
|
||||
});
|
||||
|
||||
it("falls back to an empty list when `deliveries` is not an array", async () => {
|
||||
stubFetchJson({ deliveries: "not-an-array", total: 0 });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilotDeliveries("ap-1");
|
||||
expect(res).toEqual({ deliveries: [], total: 0 });
|
||||
});
|
||||
|
||||
it("accepts an unknown future status value rather than dropping the row", async () => {
|
||||
// Server-side enum drift (e.g. new `quarantined` state). The list
|
||||
// must still surface the row; downstream UI code's `default` arm
|
||||
// handles unknown values with a generic visual.
|
||||
stubFetchJson({
|
||||
deliveries: [
|
||||
{
|
||||
id: "d-1",
|
||||
workspace_id: "ws-1",
|
||||
autopilot_id: "ap-1",
|
||||
trigger_id: "t-1",
|
||||
provider: "github",
|
||||
event: "pull_request.opened",
|
||||
dedupe_key: "abc",
|
||||
dedupe_source: "x-github-delivery",
|
||||
signature_status: "valid",
|
||||
status: "quarantined",
|
||||
attempt_count: 1,
|
||||
content_type: "application/json",
|
||||
response_status: 200,
|
||||
autopilot_run_id: null,
|
||||
replayed_from_delivery_id: null,
|
||||
error: null,
|
||||
received_at: "2026-01-01T00:00:00Z",
|
||||
last_attempt_at: "2026-01-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilotDeliveries("ap-1");
|
||||
expect(res.deliveries).toHaveLength(1);
|
||||
expect(res.deliveries[0]?.status).toBe("quarantined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutopilotDelivery", () => {
|
||||
it("falls back to a placeholder carrying the requested id", async () => {
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const detail = await client.getAutopilotDelivery("ap-1", "d-1");
|
||||
expect(detail.id).toBe("d-1");
|
||||
expect(detail.autopilot_id).toBe("ap-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAgentFromTemplate", () => {
|
||||
it("falls back to an empty agent when the response is malformed", async () => {
|
||||
// The agent was created server-side even though the client can't
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
DuplicateIssueErrorBodySchema,
|
||||
EMPTY_USER,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
RuntimeUsageByHourListSchema,
|
||||
RuntimeUsageListSchema,
|
||||
UserSchema,
|
||||
} from "./schemas";
|
||||
import { parseWithFallback } from "./schema";
|
||||
|
||||
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
|
||||
// (typed as `unknown`) through this schema. Any future server drift that
|
||||
// loses the contract MUST fail the parse so the UI falls back to a normal
|
||||
// error toast instead of rendering an empty / partial duplicate card.
|
||||
describe("DuplicateIssueErrorBodySchema", () => {
|
||||
const valid = {
|
||||
code: "active_duplicate_issue",
|
||||
error: "An active issue with this title already exists: MUL-12 – Login bug",
|
||||
issue: {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
identifier: "MUL-12",
|
||||
title: "Login bug",
|
||||
},
|
||||
};
|
||||
|
||||
it("accepts a well-formed body", () => {
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(valid).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts unknown extra fields via .loose()", () => {
|
||||
const forwardCompat = {
|
||||
...valid,
|
||||
hint: "Try a different title",
|
||||
issue: { ...valid.issue, workspace_id: "ws-1", status: "todo" },
|
||||
};
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(forwardCompat).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a renamed code (so renames degrade to the generic toast)", () => {
|
||||
const renamed = { ...valid, code: "duplicate_issue" };
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(renamed).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing issue object", () => {
|
||||
const { issue: _omit, ...without } = valid;
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-string issue.id", () => {
|
||||
const broken = { ...valid, issue: { ...valid.issue, id: 42 } };
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(broken).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a missing error field (it is optional)", () => {
|
||||
const { error: _omit, ...without } = valid;
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// `user.timezone` (Viewing tz) was added in the timezone-architecture RFC.
|
||||
// A desktop build older than the server — or a server predating the
|
||||
// `user.timezone` migration — will return a `/api/me` body with no
|
||||
// `timezone` key. The schema must not fail closed on that: the field
|
||||
// defaults to `null`, which the frontend resolves to the browser-detected
|
||||
// tz at render time.
|
||||
describe("UserSchema timezone drift", () => {
|
||||
const base = {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
name: "Ada",
|
||||
email: "ada@example.com",
|
||||
};
|
||||
|
||||
it("defaults timezone to null when the field is absent", () => {
|
||||
const parsed = UserSchema.parse(base);
|
||||
expect(parsed.timezone).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves an explicit IANA timezone", () => {
|
||||
const parsed = UserSchema.parse({ ...base, timezone: "Asia/Tokyo" });
|
||||
expect(parsed.timezone).toBe("Asia/Tokyo");
|
||||
});
|
||||
|
||||
it("accepts an explicit null timezone", () => {
|
||||
const parsed = UserSchema.parse({ ...base, timezone: null });
|
||||
expect(parsed.timezone).toBe(null);
|
||||
});
|
||||
|
||||
// Wrong-type drift: a future server bug sending `timezone` as a number
|
||||
// must not throw into the UI. parseWithFallback degrades the whole user
|
||||
// object to the explicit fallback (EMPTY_USER) so /api/me callers keep a
|
||||
// valid shape instead of white-screening.
|
||||
it("falls back to EMPTY_USER when timezone is the wrong type", () => {
|
||||
const parsed = parseWithFallback(
|
||||
{ ...base, timezone: 42 },
|
||||
UserSchema,
|
||||
EMPTY_USER,
|
||||
{ endpoint: "GET /api/me" },
|
||||
);
|
||||
expect(parsed).toBe(EMPTY_USER);
|
||||
});
|
||||
});
|
||||
|
||||
// The workspace dashboard and runtime-detail pages were re-pointed at the
|
||||
// unified `task_usage_hourly` rollup. Every numeric field drives chart /
|
||||
// KPI math, and string keys (date / agent_id / model) bucket the series.
|
||||
// The contract these schemas must hold: a row missing a field degrades
|
||||
// that field to a sane default rather than dropping the WHOLE array to
|
||||
// the `[]` fallback — one drifted row must not blank the entire chart.
|
||||
describe("dashboard + runtime usage schema drift", () => {
|
||||
it("coerces a missing numeric field to 0 instead of dropping the array", () => {
|
||||
const parsed = DashboardUsageDailyListSchema.parse([
|
||||
{ date: "2026-05-19", model: "claude-opus-4-7", input_tokens: 100 },
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.output_tokens).toBe(0);
|
||||
expect(parsed[0]?.cache_read_tokens).toBe(0);
|
||||
expect(parsed[0]?.cache_write_tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("coerces a missing date key to \"\" so the rest of the series survives", () => {
|
||||
const parsed = DashboardUsageDailyListSchema.parse([
|
||||
{ model: "claude-opus-4-7", input_tokens: 5 },
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.date).toBe("");
|
||||
});
|
||||
|
||||
it("coerces a missing agent_id key to \"\" for the agent-runtime panel", () => {
|
||||
const parsed = DashboardAgentRunTimeListSchema.parse([
|
||||
{ total_seconds: 42, task_count: 3, failed_count: 0 },
|
||||
]);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.agent_id).toBe("");
|
||||
});
|
||||
|
||||
it("coerces a missing agent_id key to \"\" for the usage-by-agent panel", () => {
|
||||
const parsed = DashboardUsageByAgentListSchema.parse([
|
||||
{ model: "claude-opus-4-7", input_tokens: 7 },
|
||||
]);
|
||||
expect(parsed[0]?.agent_id).toBe("");
|
||||
});
|
||||
|
||||
it("coerces missing fields on every runtime usage schema", () => {
|
||||
expect(RuntimeUsageListSchema.parse([{ date: "2026-05-19" }])[0]?.input_tokens).toBe(0);
|
||||
expect(RuntimeHourlyActivityListSchema.parse([{ hour: 9 }])[0]?.count).toBe(0);
|
||||
expect(RuntimeUsageByAgentListSchema.parse([{ model: "x" }])[0]?.agent_id).toBe("");
|
||||
expect(RuntimeUsageByHourListSchema.parse([{ hour: 9 }])[0]?.model).toBe("");
|
||||
});
|
||||
|
||||
it("rejects a non-array body so parseWithFallback can return its fallback", () => {
|
||||
expect(DashboardUsageDailyListSchema.safeParse(null).success).toBe(false);
|
||||
expect(RuntimeUsageListSchema.safeParse({ rows: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps unknown server-side fields via .loose()", () => {
|
||||
const parsed = RuntimeUsageListSchema.parse([
|
||||
{ date: "2026-05-19", region: "us-east" },
|
||||
]);
|
||||
expect((parsed[0] as Record<string, unknown>).region).toBe("us-east");
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,9 @@ import type {
|
||||
AgentTemplateSummary,
|
||||
Attachment,
|
||||
CreateAgentFromTemplateResponse,
|
||||
GroupedIssuesResponse,
|
||||
ListIssuesResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
TimelineEntry,
|
||||
User,
|
||||
WebhookDelivery,
|
||||
} from "../types";
|
||||
import type { CloudRuntimeNode } from "../runtimes/cloud-runtime";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas for the highest-risk API endpoints — those whose responses drive
|
||||
@@ -152,7 +147,6 @@ const IssueSchema = z.object({
|
||||
parent_issue_id: z.string().nullable(),
|
||||
project_id: z.string().nullable(),
|
||||
position: z.number(),
|
||||
start_date: z.string().nullable(),
|
||||
due_date: z.string().nullable(),
|
||||
reactions: z.array(z.unknown()).optional(),
|
||||
labels: z.array(z.unknown()).optional(),
|
||||
@@ -170,22 +164,6 @@ export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const IssueAssigneeGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
assignee_type: z.string().nullable(),
|
||||
assignee_id: z.string().nullable(),
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const GroupedIssuesResponseSchema = z.object({
|
||||
groups: z.array(IssueAssigneeGroupSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_GROUPED_ISSUES_RESPONSE: GroupedIssuesResponse = {
|
||||
groups: [],
|
||||
};
|
||||
|
||||
const SubscriberSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
user_type: z.string(),
|
||||
@@ -200,67 +178,19 @@ export const ChildIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
agent_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CloudRuntimeNodeSchema = z.object({
|
||||
id: z.string(),
|
||||
owner_id: z.string(),
|
||||
instance_id: z.string(),
|
||||
region: z.string(),
|
||||
instance_type: z.string(),
|
||||
image_id: z.string(),
|
||||
subnet_id: z.string(),
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
tags: z.record(z.string(), z.string()).default({}),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CloudRuntimeNodeListSchema = z.array(CloudRuntimeNodeSchema);
|
||||
|
||||
export const EMPTY_CLOUD_RUNTIME_NODE_LIST: CloudRuntimeNode[] = [];
|
||||
|
||||
export const EMPTY_CLOUD_RUNTIME_NODE: CloudRuntimeNode = {
|
||||
id: "",
|
||||
owner_id: "",
|
||||
instance_id: "",
|
||||
region: "",
|
||||
instance_type: "",
|
||||
image_id: "",
|
||||
subnet_id: "",
|
||||
name: "",
|
||||
status: "",
|
||||
tags: {},
|
||||
metadata: {},
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace dashboard schemas
|
||||
//
|
||||
// The dashboard hits three independent rollup endpoints. Each returns a flat
|
||||
// array, and every field is consumed by chart / KPI math — a missing number
|
||||
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
|
||||
// String fields default to "" (no enum narrowing) to survive future model /
|
||||
// agent ID drift, and so a single null from tz-aware SQL bucketing fails
|
||||
// only that row instead of dropping the whole array to the `[]` fallback.
|
||||
// String fields stay lenient (no enum narrowing) to survive future model /
|
||||
// agent ID drift.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DashboardUsageDailySchema = z.object({
|
||||
date: z.string().default(""),
|
||||
model: z.string().default(""),
|
||||
date: z.string(),
|
||||
model: z.string(),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
@@ -271,8 +201,8 @@ const DashboardUsageDailySchema = z.object({
|
||||
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
|
||||
|
||||
const DashboardUsageByAgentSchema = z.object({
|
||||
agent_id: z.string().default(""),
|
||||
model: z.string().default(""),
|
||||
agent_id: z.string(),
|
||||
model: z.string(),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
@@ -283,7 +213,7 @@ const DashboardUsageByAgentSchema = z.object({
|
||||
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
|
||||
|
||||
const DashboardAgentRunTimeSchema = z.object({
|
||||
agent_id: z.string().default(""),
|
||||
agent_id: z.string(),
|
||||
total_seconds: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
failed_count: z.number().default(0),
|
||||
@@ -291,66 +221,6 @@ const DashboardAgentRunTimeSchema = z.object({
|
||||
|
||||
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
|
||||
|
||||
const DashboardRunTimeDailySchema = z.object({
|
||||
date: z.string().default(""),
|
||||
total_seconds: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
failed_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const DashboardRunTimeDailyListSchema = z.array(DashboardRunTimeDailySchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime usage schemas — the runtime-detail page's four usage endpoints
|
||||
// (`/api/runtimes/:id/usage*`). Same leniency rules as the dashboard
|
||||
// schemas above: numbers default to 0, strings to "", `.loose()` passes
|
||||
// unknown fields.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RuntimeUsageSchema = z.object({
|
||||
runtime_id: z.string().default(""),
|
||||
date: z.string().default(""),
|
||||
provider: z.string().default(""),
|
||||
model: z.string().default(""),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
cache_write_tokens: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const RuntimeUsageListSchema = z.array(RuntimeUsageSchema);
|
||||
|
||||
const RuntimeHourlyActivitySchema = z.object({
|
||||
hour: z.number().default(0),
|
||||
count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const RuntimeHourlyActivityListSchema = z.array(RuntimeHourlyActivitySchema);
|
||||
|
||||
const RuntimeUsageByAgentSchema = z.object({
|
||||
agent_id: z.string().default(""),
|
||||
model: z.string().default(""),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
cache_write_tokens: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const RuntimeUsageByAgentListSchema = z.array(RuntimeUsageByAgentSchema);
|
||||
|
||||
const RuntimeUsageByHourSchema = z.object({
|
||||
hour: z.number().default(0),
|
||||
model: z.string().default(""),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
cache_write_tokens: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent template catalog — `/api/agent-templates*` and the
|
||||
// create-from-template response. The desktop app's create-agent picker
|
||||
@@ -436,181 +306,3 @@ export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateR
|
||||
imported_skill_ids: [],
|
||||
reused_skill_ids: [],
|
||||
};
|
||||
|
||||
// Squad member status — backs the Squad detail page's Members tab. status
|
||||
// is `string | null` (not the narrow `SquadMemberStatusValue` union) so a
|
||||
// new server-side status doesn't fail the parse; the UI defaults to a
|
||||
// neutral pill for unknown values.
|
||||
const SquadActiveIssueBriefSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
issue_status: z.string(),
|
||||
}).loose();
|
||||
|
||||
const SquadMemberStatusSchema = z.object({
|
||||
member_type: z.string(),
|
||||
member_id: z.string(),
|
||||
status: z.string().nullable().optional().transform((v) => v ?? null),
|
||||
active_issues: z.array(SquadActiveIssueBriefSchema).default([]),
|
||||
last_active_at: z.string().nullable().optional().transform((v) => v ?? null),
|
||||
}).loose();
|
||||
|
||||
export const SquadMemberStatusListResponseSchema = z.object({
|
||||
members: z.array(SquadMemberStatusSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_SQUAD_MEMBER_STATUS_LIST = { members: [] };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structured error body — POST /api/workspaces/:wsId/issues 409 conflict.
|
||||
//
|
||||
// When the server detects an active issue with the same title in the same
|
||||
// workspace, it returns `{ code: "active_duplicate_issue", error, issue }`
|
||||
// instead of letting the create through. The UI uses the embedded issue ref
|
||||
// to offer "view existing" rather than dropping the user into a generic
|
||||
// "create failed" toast.
|
||||
//
|
||||
// Strict guarantees:
|
||||
// - `code` is a literal so a future server rename (e.g. `duplicate_issue`)
|
||||
// fails the parse and falls back to a normal error toast — drift never
|
||||
// ships as a broken duplicate UI.
|
||||
// - `issue` is required; without an id/identifier/title the "view existing"
|
||||
// button has nothing to point at, so we'd rather fall back than guess.
|
||||
// - `issue.status` is intentionally OMITTED: the duplicate toast doesn't
|
||||
// render a StatusIcon (which has no fallback for unknown enum values),
|
||||
// so a future server-side rename of `status` must not knock this branch
|
||||
// out. `.loose()` lets the field pass through unchanged for any other
|
||||
// consumer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DuplicateIssueErrorBodySchema = z.object({
|
||||
code: z.literal("active_duplicate_issue"),
|
||||
error: z.string().optional(),
|
||||
issue: z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
}).loose(),
|
||||
}).loose();
|
||||
|
||||
export interface DuplicateIssueErrorBody {
|
||||
code: "active_duplicate_issue";
|
||||
error?: string;
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook delivery schemas — backing the Autopilot Deliveries section. Enums
|
||||
// (`status`, `signature_status`, `provider`) are kept as `z.string()` so a
|
||||
// future server-side value (e.g. a Stripe provider, a new dedupe state)
|
||||
// degrades to a generic UI fallback rather than collapsing the list into
|
||||
// the empty array. `.loose()` lets unknown fields pass through, matching
|
||||
// the rule used by every other endpoint here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WebhookDeliverySchema = z.object({
|
||||
id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
autopilot_id: z.string(),
|
||||
trigger_id: z.string(),
|
||||
provider: z.string(),
|
||||
event: z.string(),
|
||||
dedupe_key: z.string().nullable(),
|
||||
dedupe_source: z.string().nullable(),
|
||||
signature_status: z.string(),
|
||||
status: z.string(),
|
||||
attempt_count: z.number().default(0),
|
||||
content_type: z.string().nullable(),
|
||||
response_status: z.number().nullable(),
|
||||
autopilot_run_id: z.string().nullable(),
|
||||
replayed_from_delivery_id: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
received_at: z.string(),
|
||||
last_attempt_at: z.string(),
|
||||
created_at: z.string(),
|
||||
// Detail-only fields. The list endpoint omits them; the detail endpoint
|
||||
// populates raw_body / selected_headers / response_body.
|
||||
selected_headers: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
raw_body: z.string().nullable().optional(),
|
||||
response_body: z.string().nullable().optional(),
|
||||
}).loose();
|
||||
|
||||
export const ListWebhookDeliveriesResponseSchema = z.object({
|
||||
deliveries: z.array(WebhookDeliverySchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const WebhookDeliveryResponseSchema = WebhookDeliverySchema;
|
||||
|
||||
export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesResponse = {
|
||||
deliveries: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
|
||||
id: "",
|
||||
workspace_id: "",
|
||||
autopilot_id: "",
|
||||
trigger_id: "",
|
||||
provider: "",
|
||||
event: "",
|
||||
dedupe_key: null,
|
||||
dedupe_source: null,
|
||||
signature_status: "not_required",
|
||||
status: "queued",
|
||||
attempt_count: 0,
|
||||
content_type: null,
|
||||
response_status: null,
|
||||
autopilot_run_id: null,
|
||||
replayed_from_delivery_id: null,
|
||||
error: null,
|
||||
received_at: "",
|
||||
last_attempt_at: "",
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User (`/api/me` GET + PATCH). The auth store and Settings → Account both
|
||||
// trust this shape — a drift here would knock both surfaces out. Kept
|
||||
// lenient by the same rules as IssueSchema: enums stay `z.string()`,
|
||||
// nullable fields are unioned with `null`, unknown server fields pass
|
||||
// through via `.loose()`. `profile_description` is the field added in
|
||||
// MUL-2406; the server emits `""` when unset (NOT NULL DEFAULT ''), so
|
||||
// the schema defaults to `""` too — keeps the type tight without
|
||||
// breaking older backends that don't return the column yet.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().default(""),
|
||||
email: z.string().default(""),
|
||||
avatar_url: z.string().nullable().default(null),
|
||||
onboarded_at: z.string().nullable().default(null),
|
||||
onboarding_questionnaire: z.record(z.string(), z.unknown()).default({}),
|
||||
starter_content_state: z.string().nullable().default(null),
|
||||
language: z.string().nullable().default(null),
|
||||
profile_description: z.string().default(""),
|
||||
timezone: z.string().nullable().default(null),
|
||||
created_at: z.string().default(""),
|
||||
updated_at: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_USER: User = {
|
||||
id: "",
|
||||
name: "",
|
||||
email: "",
|
||||
avatar_url: null,
|
||||
onboarded_at: null,
|
||||
onboarding_questionnaire: {},
|
||||
starter_content_state: null,
|
||||
language: null,
|
||||
profile_description: "",
|
||||
timezone: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { WSClient } from "./ws-client";
|
||||
// upgrade URL construction, which is what carries client identity.
|
||||
class FakeWebSocket {
|
||||
static lastUrl: string | null = null;
|
||||
static lastInstance: FakeWebSocket | null = null;
|
||||
// Fields read by WSClient.connect()/disconnect(), all no-op here.
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
@@ -15,7 +14,6 @@ class FakeWebSocket {
|
||||
readyState = 0;
|
||||
constructor(url: string) {
|
||||
FakeWebSocket.lastUrl = url;
|
||||
FakeWebSocket.lastInstance = this;
|
||||
}
|
||||
close() {}
|
||||
send() {}
|
||||
@@ -24,7 +22,6 @@ class FakeWebSocket {
|
||||
describe("WSClient", () => {
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.lastUrl = null;
|
||||
FakeWebSocket.lastInstance = null;
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
});
|
||||
|
||||
@@ -72,59 +69,4 @@ describe("WSClient", () => {
|
||||
expect(url.searchParams.has("client_version")).toBe(false);
|
||||
expect(url.searchParams.has("client_os")).toBe(false);
|
||||
});
|
||||
|
||||
it("truncates the logged payload when an unparseable frame is large", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const ws = new WSClient("ws://example.test/ws", { logger });
|
||||
ws.connect();
|
||||
|
||||
const huge = "x".repeat(5000);
|
||||
FakeWebSocket.lastInstance!.onmessage?.({ data: huge });
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [, summary] = logger.warn.mock.calls[0] as [string, string];
|
||||
expect(summary.length).toBeLessThan(huge.length);
|
||||
expect(summary).toContain("truncated");
|
||||
expect(summary).toContain("5000");
|
||||
expect(summary.startsWith("x".repeat(200))).toBe(true);
|
||||
});
|
||||
|
||||
it("logs and skips malformed frames without breaking later messages", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const ws = new WSClient("ws://example.test/ws", { logger });
|
||||
const handler = vi.fn();
|
||||
ws.on("issue:updated", handler);
|
||||
ws.connect();
|
||||
|
||||
expect(() => {
|
||||
FakeWebSocket.lastInstance!.onmessage?.({ data: `{"type":"issue` });
|
||||
}).not.toThrow();
|
||||
|
||||
FakeWebSocket.lastInstance!.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "issue:updated",
|
||||
payload: { id: "issue-1" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"ws: received unparseable message",
|
||||
`{"type":"issue`,
|
||||
);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ id: "issue-1" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import type { WSMessage, WSEventType } from "../types/events";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
|
||||
|
||||
// Cap how much of an unparseable frame we put into the log. A malformed or
|
||||
// rogue server can stream arbitrarily large garbage, and the warn handler may
|
||||
// be a console / IPC bridge whose buffers we don't want to blow.
|
||||
const UNPARSEABLE_LOG_MAX_CHARS = 200;
|
||||
|
||||
function summarizeUnparseable(data: unknown): string {
|
||||
const text = typeof data === "string" ? data : String(data);
|
||||
if (text.length <= UNPARSEABLE_LOG_MAX_CHARS) return text;
|
||||
return `${text.slice(0, UNPARSEABLE_LOG_MAX_CHARS)}… (truncated, ${text.length} chars total)`;
|
||||
}
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
|
||||
/** Identifies the WS client to the server. Sent as `client_platform`,
|
||||
* `client_version`, and `client_os` query parameters on the upgrade URL —
|
||||
@@ -86,16 +75,7 @@ export class WSClient {
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
let msg: WSMessage;
|
||||
try {
|
||||
msg = JSON.parse(event.data as string) as WSMessage;
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
"ws: received unparseable message",
|
||||
summarizeUnparseable(event.data),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const msg = JSON.parse(event.data as string) as WSMessage;
|
||||
if ((msg as any).type === "auth_ack") {
|
||||
this.onAuthenticated();
|
||||
return;
|
||||
@@ -104,7 +84,7 @@ export class WSClient {
|
||||
const eventHandlers = this.handlers.get(msg.type);
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
handler(msg.payload, msg.actor_id, msg.actor_type);
|
||||
handler(msg.payload, msg.actor_id);
|
||||
}
|
||||
}
|
||||
for (const handler of this.anyHandlers) {
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
export {
|
||||
autopilotKeys,
|
||||
autopilotListOptions,
|
||||
autopilotDetailOptions,
|
||||
autopilotRunsOptions,
|
||||
autopilotDeliveriesOptions,
|
||||
autopilotDeliveryOptions,
|
||||
} from "./queries";
|
||||
export { autopilotKeys, autopilotListOptions, autopilotDetailOptions, autopilotRunsOptions } from "./queries";
|
||||
export {
|
||||
useCreateAutopilot,
|
||||
useUpdateAutopilot,
|
||||
@@ -14,7 +7,4 @@ export {
|
||||
useCreateAutopilotTrigger,
|
||||
useUpdateAutopilotTrigger,
|
||||
useDeleteAutopilotTrigger,
|
||||
useRotateAutopilotTriggerWebhookToken,
|
||||
useReplayAutopilotDelivery,
|
||||
} from "./mutations";
|
||||
export { buildAutopilotWebhookUrl } from "./webhook";
|
||||
|
||||
@@ -128,32 +128,3 @@ export function useDeleteAutopilotTrigger() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRotateAutopilotTriggerWebhookToken() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, triggerId }: { autopilotId: string; triggerId: string }) =>
|
||||
api.rotateAutopilotTriggerWebhookToken(autopilotId, triggerId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Replay re-dispatches a previously-recorded delivery. The server creates
|
||||
// a new delivery row (with `replayed_from_delivery_id`) and synchronously
|
||||
// kicks off a new autopilot run. We invalidate both deliveries and runs so
|
||||
// the new delivery and any resulting run show up immediately.
|
||||
export function useReplayAutopilotDelivery() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, deliveryId }: { autopilotId: string; deliveryId: string }) =>
|
||||
api.replayAutopilotDelivery(autopilotId, deliveryId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.deliveries(wsId, vars.autopilotId) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.runs(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,12 +8,6 @@ export const autopilotKeys = {
|
||||
[...autopilotKeys.all(wsId), "detail", id] as const,
|
||||
runs: (wsId: string, id: string) =>
|
||||
[...autopilotKeys.all(wsId), "runs", id] as const,
|
||||
run: (wsId: string, autopilotId: string, runId: string) =>
|
||||
[...autopilotKeys.all(wsId), "runs", autopilotId, runId] as const,
|
||||
deliveries: (wsId: string, id: string) =>
|
||||
[...autopilotKeys.all(wsId), "deliveries", id] as const,
|
||||
delivery: (wsId: string, autopilotId: string, deliveryId: string) =>
|
||||
[...autopilotKeys.all(wsId), "deliveries", autopilotId, deliveryId] as const,
|
||||
};
|
||||
|
||||
export function autopilotListOptions(wsId: string) {
|
||||
@@ -38,52 +32,3 @@ export function autopilotRunsOptions(wsId: string, id: string) {
|
||||
select: (data) => data.runs,
|
||||
});
|
||||
}
|
||||
|
||||
// autopilotRunOptions fetches a single run with its full trigger_payload.
|
||||
// The list endpoint (autopilotRunsOptions) omits trigger_payload to keep
|
||||
// list responses small; callers (e.g. the run-detail dialog) use this
|
||||
// query on demand when the user opens a run.
|
||||
export function autopilotRunOptions(
|
||||
wsId: string,
|
||||
autopilotId: string,
|
||||
runId: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.run(wsId, autopilotId, runId),
|
||||
queryFn: () => api.getAutopilotRun(autopilotId, runId),
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// autopilotDeliveriesOptions powers the Deliveries section in the autopilot
|
||||
// detail page. The list is slim — raw_body / selected_headers / response_body
|
||||
// are omitted server-side. Detail rows are fetched on-demand when the user
|
||||
// expands a row (see autopilotDeliveryOptions).
|
||||
export function autopilotDeliveriesOptions(
|
||||
wsId: string,
|
||||
autopilotId: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.deliveries(wsId, autopilotId),
|
||||
queryFn: () => api.listAutopilotDeliveries(autopilotId),
|
||||
select: (data) => data.deliveries,
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// autopilotDeliveryOptions fetches the full delivery row including raw_body
|
||||
// and headers subset. Used by the detail dialog opened from a list row.
|
||||
export function autopilotDeliveryOptions(
|
||||
wsId: string,
|
||||
autopilotId: string,
|
||||
deliveryId: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.delivery(wsId, autopilotId, deliveryId),
|
||||
queryFn: () => api.getAutopilotDelivery(autopilotId, deliveryId),
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAutopilotWebhookUrl } from "./webhook";
|
||||
import type { AutopilotTrigger } from "../types";
|
||||
|
||||
const baseTrigger: AutopilotTrigger = {
|
||||
id: "t1",
|
||||
autopilot_id: "a1",
|
||||
kind: "webhook",
|
||||
enabled: true,
|
||||
cron_expression: null,
|
||||
timezone: null,
|
||||
next_run_at: null,
|
||||
webhook_token: "awt_abc",
|
||||
webhook_path: "/api/webhooks/autopilots/awt_abc",
|
||||
webhook_url: null,
|
||||
label: null,
|
||||
last_fired_at: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
describe("buildAutopilotWebhookUrl", () => {
|
||||
it("returns the server-provided webhook_url verbatim when present", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: { ...baseTrigger, webhook_url: "https://custom.example/api/webhooks/autopilots/awt_abc" },
|
||||
}),
|
||||
).toBe("https://custom.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("composes from apiBaseUrl + webhook_path", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example" }),
|
||||
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("strips trailing slash on apiBaseUrl", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example/" }),
|
||||
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("falls back to currentOrigin when apiBaseUrl is empty", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: baseTrigger,
|
||||
apiBaseUrl: "",
|
||||
currentOrigin: "https://app.example",
|
||||
}),
|
||||
).toBe("https://app.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("composes from token when webhook_path is missing", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: { ...baseTrigger, webhook_path: null },
|
||||
apiBaseUrl: "https://api.example",
|
||||
}),
|
||||
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("returns null for non-webhook trigger", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: { ...baseTrigger, kind: "schedule", webhook_token: null, webhook_path: null },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns relative path when no base or origin available", () => {
|
||||
expect(buildAutopilotWebhookUrl({ trigger: baseTrigger })).toBe("/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { AutopilotTrigger } from "../types";
|
||||
|
||||
/**
|
||||
* Compose a usable absolute webhook URL for a webhook trigger.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. trigger.webhook_url — present only when MULTICA_PUBLIC_URL is set on the
|
||||
* server. This is the authoritative form when available.
|
||||
* 2. apiBaseUrl + webhook_path — desktop apps and self-host setups where the
|
||||
* server didn't mint an absolute URL but the client knows its API origin.
|
||||
* 3. currentOrigin + webhook_path — browser fallback when getBaseUrl() is
|
||||
* empty (e.g. same-origin Next.js dev).
|
||||
*
|
||||
* Returns null when the trigger has no token / path yet (a new trigger that
|
||||
* hasn't been written back to the cache, or a non-webhook trigger).
|
||||
*/
|
||||
export function buildAutopilotWebhookUrl(params: {
|
||||
trigger: Pick<AutopilotTrigger, "kind" | "webhook_token" | "webhook_path" | "webhook_url">;
|
||||
apiBaseUrl?: string;
|
||||
currentOrigin?: string;
|
||||
}): string | null {
|
||||
const { trigger, apiBaseUrl, currentOrigin } = params;
|
||||
|
||||
if (trigger.kind !== "webhook") return null;
|
||||
|
||||
if (typeof trigger.webhook_url === "string" && trigger.webhook_url) {
|
||||
return trigger.webhook_url;
|
||||
}
|
||||
|
||||
const path =
|
||||
(typeof trigger.webhook_path === "string" && trigger.webhook_path) ||
|
||||
(trigger.webhook_token ? `/api/webhooks/autopilots/${trigger.webhook_token}` : null);
|
||||
if (!path) return null;
|
||||
|
||||
const base = stripTrailingSlash(apiBaseUrl) || stripTrailingSlash(currentOrigin);
|
||||
if (!base) return path; // last resort — relative path will still work in-browser
|
||||
return base + path;
|
||||
}
|
||||
|
||||
function stripTrailingSlash(s: string | undefined): string {
|
||||
if (!s) return "";
|
||||
return s.endsWith("/") ? s.slice(0, -1) : s;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isTaskMessageTaskId, taskMessagesOptions } from "./queries";
|
||||
|
||||
describe("taskMessagesOptions", () => {
|
||||
it("fetches task messages for persisted UUID task ids", () => {
|
||||
const taskId = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc";
|
||||
|
||||
expect(isTaskMessageTaskId(taskId)).toBe(true);
|
||||
expect(taskMessagesOptions(taskId).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fetch task messages for optimistic task ids", () => {
|
||||
const taskId = "optimistic-optimistic-1778739487737";
|
||||
|
||||
expect(isTaskMessageTaskId(taskId)).toBe(false);
|
||||
expect(taskMessagesOptions(taskId).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,12 +21,6 @@ export const chatKeys = {
|
||||
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
|
||||
};
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isTaskMessageTaskId(taskId: string | null | undefined): taskId is string {
|
||||
return typeof taskId === "string" && UUID_PATTERN.test(taskId);
|
||||
}
|
||||
|
||||
export function chatSessionsOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.sessions(wsId),
|
||||
@@ -76,7 +70,7 @@ export function taskMessagesOptions(taskId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.taskMessages(taskId),
|
||||
queryFn: () => api.listTaskMessages(taskId),
|
||||
enabled: isTaskMessageTaskId(taskId),
|
||||
enabled: !!taskId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,55 +1,43 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
// Workspace dashboard query options. All three endpoints share the same
|
||||
// (wsId, days, projectId) key shape so workspace switching, time-range
|
||||
// changes, and the project filter each invalidate the cache cleanly.
|
||||
//
|
||||
// The cache key includes `wsId` explicitly: TanStack Query already isolates
|
||||
// per workspace via the key, but threading wsId into the queryFn lets
|
||||
// callers fail fast (return [] on empty wsId) instead of issuing a request
|
||||
// the server would reject.
|
||||
//
|
||||
// `projectId` is normalised to `null` (not undefined / "all") so the
|
||||
// queryKey shape is stable across renders even when the dropdown sits on
|
||||
// "all projects".
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: (wsId: string) => ["dashboard", wsId] as const,
|
||||
daily: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) => [...dashboardKeys.all(wsId), "daily", days, projectId, tz] as const,
|
||||
byAgent: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) => [...dashboardKeys.all(wsId), "by-agent", days, projectId, tz] as const,
|
||||
agentRuntime: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) => [...dashboardKeys.all(wsId), "agent-runtime", days, projectId, tz] as const,
|
||||
runTimeDaily: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) => [...dashboardKeys.all(wsId), "runtime-daily", days, projectId, tz] as const,
|
||||
daily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
|
||||
byAgent: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
|
||||
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
|
||||
};
|
||||
|
||||
// 5-min rollup cadence on the server, 60s background refetch on the client.
|
||||
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
|
||||
// driven on the server (5-min rollup cadence) and the dashboard isn't a
|
||||
// real-time view, so background refetches every minute are plenty.
|
||||
const STALE_TIME = 60 * 1000;
|
||||
|
||||
// `tz` participates in every dashboard key so a Preferences change
|
||||
// repoints the cache. All four series — token rollups and the
|
||||
// atq.completed_at-based run-time series — slice their day boundary in
|
||||
// the viewer's tz, so the four dashboard tabs always agree.
|
||||
export function dashboardUsageDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId, tz),
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -59,16 +47,11 @@ export function dashboardUsageByAgentOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId, tz),
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageByAgent({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -78,35 +61,11 @@ export function dashboardAgentRunTimeOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId, tz),
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardAgentRunTime({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function dashboardRunTimeDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
tz: string,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId, tz),
|
||||
queryFn: () =>
|
||||
api.getDashboardRunTimeDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
tz,
|
||||
}),
|
||||
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
export * from "./queries";
|
||||
export * from "./pull-request-status";
|
||||
export * from "./settings";
|
||||
export * from "./use-github-settings";
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
derivePullRequestStatusKind,
|
||||
derivePullRequestProgressSegments,
|
||||
shouldShowPullRequestStats,
|
||||
type PullRequestStatusInput,
|
||||
} from "./pull-request-status";
|
||||
|
||||
const base: PullRequestStatusInput = { state: "open" };
|
||||
|
||||
describe("derivePullRequestStatusKind", () => {
|
||||
it("closed beats every other signal", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
state: "closed",
|
||||
mergeable_state: "dirty",
|
||||
checks_failed: 99,
|
||||
checks_pending: 99,
|
||||
checks_passed: 99,
|
||||
}),
|
||||
).toBe("closed");
|
||||
});
|
||||
|
||||
it("merged beats every other signal except closed", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
state: "merged",
|
||||
mergeable_state: "dirty",
|
||||
checks_failed: 5,
|
||||
}),
|
||||
).toBe("merged");
|
||||
});
|
||||
|
||||
it("dirty conflicts wins over check signals", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
mergeable_state: "dirty",
|
||||
checks_passed: 3,
|
||||
}),
|
||||
).toBe("conflicts");
|
||||
});
|
||||
|
||||
it("any failed check beats pending and passed", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 3,
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_failed");
|
||||
});
|
||||
|
||||
it("pending beats passed when no failure", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
checks_pending: 1,
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_pending");
|
||||
});
|
||||
|
||||
it("all-passed is checks_passed regardless of mergeable=clean", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
mergeable_state: "clean",
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_passed");
|
||||
});
|
||||
|
||||
it("clean + no suites is ready-to-merge", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({ ...base, mergeable_state: "clean" }),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("opaque mergeable values render as unknown", () => {
|
||||
for (const m of ["blocked", "behind", "unstable", "has_hooks", "unknown", null, undefined]) {
|
||||
expect(derivePullRequestStatusKind({ ...base, mergeable_state: m })).toBe("unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePullRequestProgressSegments", () => {
|
||||
it("returns null for terminal PRs (merged / closed)", () => {
|
||||
expect(derivePullRequestProgressSegments({ state: "merged", checks_passed: 5 })).toBeNull();
|
||||
expect(derivePullRequestProgressSegments({ state: "closed", checks_failed: 3 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no suite has been observed", () => {
|
||||
expect(derivePullRequestProgressSegments({ ...base })).toBeNull();
|
||||
expect(
|
||||
derivePullRequestProgressSegments({ ...base, checks_failed: 0, checks_pending: 0, checks_passed: 0 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("orders segments failed → pending → passed (failure leftmost)", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 2,
|
||||
checks_passed: 3,
|
||||
});
|
||||
expect(segs).not.toBeNull();
|
||||
expect(segs!.map((s) => s.kind)).toEqual(["failed", "pending", "passed"]);
|
||||
});
|
||||
|
||||
it("emits a zero-width segment-free output (no entry with ratio 0)", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 0,
|
||||
checks_pending: 0,
|
||||
checks_passed: 4,
|
||||
});
|
||||
expect(segs).toEqual([{ kind: "passed", ratio: 1 }]);
|
||||
});
|
||||
|
||||
it("ratios sum to ~1 across segments", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 1,
|
||||
checks_passed: 2,
|
||||
})!;
|
||||
const total = segs.reduce((acc, s) => acc + s.ratio, 0);
|
||||
expect(total).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowPullRequestStats", () => {
|
||||
it("hides when every field is 0 or missing (legacy backend)", () => {
|
||||
expect(shouldShowPullRequestStats({})).toBe(false);
|
||||
expect(shouldShowPullRequestStats({ additions: 0, deletions: 0, changed_files: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows when at least one number is non-zero", () => {
|
||||
expect(shouldShowPullRequestStats({ additions: 1 })).toBe(true);
|
||||
expect(shouldShowPullRequestStats({ deletions: 1 })).toBe(true);
|
||||
expect(shouldShowPullRequestStats({ changed_files: 1 })).toBe(true);
|
||||
expect(shouldShowPullRequestStats({ additions: 437, deletions: 6, changed_files: 6 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { GitHubPullRequest } from "../types";
|
||||
|
||||
// Status kinds rendered in the PR sidebar row's detail line. Order in the
|
||||
// pass-through table matters — the first matching rule wins. The order is
|
||||
// chosen so terminal PR states (closed / merged) short-circuit before any
|
||||
// transient CI/conflict signal, since those signals are no longer actionable
|
||||
// on a terminal PR.
|
||||
//
|
||||
// Priority (high → low):
|
||||
// 1. closed (not merged) → status_closed
|
||||
// 2. merged → status_merged
|
||||
// 3. mergeable_state = "dirty" → status_conflicts
|
||||
// 4. any failed suite → status_checks_failed
|
||||
// 5. any pending suite → status_checks_pending
|
||||
// 6. any passed suite → status_checks_passed
|
||||
// 7. no suite + mergeable=clean → status_ready
|
||||
// 8. otherwise → status_unknown
|
||||
//
|
||||
// Note: this table is the single source of truth for the sidebar PR row. The
|
||||
// older row-with-badges implementation used a separate "hide status row for
|
||||
// terminal PRs" branch — the current row renders
|
||||
// with status_closed / status_merged text, never falling through to a
|
||||
// conflicts / checks line on a terminal PR. Keep this priority order in sync
|
||||
// with the i18n keys `pull_request_card_status_*` and with the progress-strip
|
||||
// derivation in `derivePullRequestProgressSegments` (terminal kinds get a
|
||||
// solid bar; the rest map onto the per-suite counts).
|
||||
export type PullRequestStatusKind =
|
||||
| "closed"
|
||||
| "merged"
|
||||
| "conflicts"
|
||||
| "checks_failed"
|
||||
| "checks_pending"
|
||||
| "checks_passed"
|
||||
| "ready"
|
||||
| "unknown";
|
||||
|
||||
export interface PullRequestStatusInput {
|
||||
state: GitHubPullRequest["state"];
|
||||
mergeable_state?: string | null;
|
||||
checks_failed?: number;
|
||||
checks_pending?: number;
|
||||
checks_passed?: number;
|
||||
}
|
||||
|
||||
export function derivePullRequestStatusKind(input: PullRequestStatusInput): PullRequestStatusKind {
|
||||
if (input.state === "closed") return "closed";
|
||||
if (input.state === "merged") return "merged";
|
||||
if (input.mergeable_state === "dirty") return "conflicts";
|
||||
if ((input.checks_failed ?? 0) > 0) return "checks_failed";
|
||||
if ((input.checks_pending ?? 0) > 0) return "checks_pending";
|
||||
if ((input.checks_passed ?? 0) > 0) return "checks_passed";
|
||||
if (input.mergeable_state === "clean") return "ready";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export interface PullRequestProgressSegment {
|
||||
kind: "failed" | "pending" | "passed";
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
// Segmented progress bar input. Returns null when:
|
||||
// - the PR is terminal (closed/merged) — the card paints a solid bar
|
||||
// in a state-specific color, no segmentation needed;
|
||||
// - no check_suite has been observed (total === 0) — the card hides
|
||||
// the bar entirely.
|
||||
// Otherwise emits the segments left-to-right: failed → pending → passed.
|
||||
// "Failure first" is intentional: problems should be visible before signal
|
||||
// that everything is fine.
|
||||
export function derivePullRequestProgressSegments(
|
||||
input: PullRequestStatusInput,
|
||||
): PullRequestProgressSegment[] | null {
|
||||
if (input.state === "closed" || input.state === "merged") return null;
|
||||
const failed = input.checks_failed ?? 0;
|
||||
const pending = input.checks_pending ?? 0;
|
||||
const passed = input.checks_passed ?? 0;
|
||||
const total = failed + pending + passed;
|
||||
if (total === 0) return null;
|
||||
const segments: PullRequestProgressSegment[] = [];
|
||||
if (failed > 0) segments.push({ kind: "failed", ratio: failed / total });
|
||||
if (pending > 0) segments.push({ kind: "pending", ratio: pending / total });
|
||||
if (passed > 0) segments.push({ kind: "passed", ratio: passed / total });
|
||||
return segments;
|
||||
}
|
||||
|
||||
export interface PullRequestStatsInput {
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changed_files?: number;
|
||||
}
|
||||
|
||||
// shouldShowPullRequestStats encodes the "old backend → new frontend" guard:
|
||||
// when the backend that served this PR row doesn't know about the stats
|
||||
// columns yet, every numeric field defaults to 0. Rendering "+0 −0 · 0 files"
|
||||
// in that case would be a lie (the PR almost certainly has real changes),
|
||||
// so we hide the entire stats row until at least one signal is non-zero.
|
||||
export function shouldShowPullRequestStats(input: PullRequestStatsInput): boolean {
|
||||
const a = input.additions ?? 0;
|
||||
const d = input.deletions ?? 0;
|
||||
const f = input.changed_files ?? 0;
|
||||
return a + d + f > 0;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user