mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Compare commits
55 Commits
agent/j/27
...
agent/gpt-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6adae11e98 | ||
|
|
22b7573303 | ||
|
|
90a737fc7e | ||
|
|
7bbca54e92 | ||
|
|
90ddfb04e2 | ||
|
|
09f9c7e2ce | ||
|
|
03f70209c4 | ||
|
|
72e0cb70aa | ||
|
|
3943358e67 | ||
|
|
5e78e5100a | ||
|
|
f26bdffaf2 | ||
|
|
1195255e43 | ||
|
|
bae8a84abd | ||
|
|
d39da9f7f0 | ||
|
|
202200bc62 | ||
|
|
56bddc5e06 | ||
|
|
fe2c990296 | ||
|
|
5732b0dae8 | ||
|
|
1947830d4b | ||
|
|
2bda4065d0 | ||
|
|
7722a98a6a | ||
|
|
ccbd62c7ad | ||
|
|
ec5874db96 | ||
|
|
4864831721 | ||
|
|
be32e5af00 | ||
|
|
bed032f937 | ||
|
|
171ee842d4 | ||
|
|
85daf7818a | ||
|
|
f02bc56e70 | ||
|
|
bdb60acae9 | ||
|
|
d16ed2806a | ||
|
|
746c0c4456 | ||
|
|
2b5696703f | ||
|
|
e3723dbb22 | ||
|
|
607e64d722 | ||
|
|
bd1fb10afa | ||
|
|
c968c13c87 | ||
|
|
963c33f030 | ||
|
|
31b58494cf | ||
|
|
0dcc35edc8 | ||
|
|
17714c3ad1 | ||
|
|
d15da372a9 | ||
|
|
329fc0139d | ||
|
|
341ce7bfa5 | ||
|
|
2af2f7b3e4 | ||
|
|
7bc1aa7563 | ||
|
|
b343e13ec4 | ||
|
|
668fe99cce | ||
|
|
6261e2b6b2 | ||
|
|
963ed5cd0e | ||
|
|
7d24a8594a | ||
|
|
730fb61f4a | ||
|
|
e55f050b84 | ||
|
|
fa2a0e57ec | ||
|
|
df02fcf175 |
@@ -208,6 +208,14 @@ ALLOWED_EMAIL_DOMAINS=
|
||||
# Optional: Only allow these exact email addresses (comma-separated)
|
||||
ALLOWED_EMAILS=
|
||||
|
||||
# Set to "true" to disable workspace creation for every caller on this
|
||||
# instance (#3433). Operators usually leave this unset, bootstrap the
|
||||
# shared workspace, then flip this to "true" and restart so subsequent
|
||||
# users join only via invitations and the entire deployment is visible to
|
||||
# the platform admin. The web UI reads this from /api/config at runtime,
|
||||
# so toggling requires a backend restart but not a frontend rebuild.
|
||||
DISABLE_WORKSPACE_CREATION=
|
||||
|
||||
# ==================== Analytics (PostHog) ====================
|
||||
# Product analytics events feed the acquisition → activation → expansion funnel.
|
||||
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -42,7 +42,12 @@ jobs:
|
||||
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
|
||||
|
||||
- name: Build, type check, lint, and test
|
||||
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
|
||||
# Mobile lives in a parallel mobile-verify workflow (path-filtered
|
||||
# to apps/mobile/** + packages/core/types/**) so it doesn't add
|
||||
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
|
||||
# filter in sync with the root package.json scripts, which also
|
||||
# exclude @multica/mobile.
|
||||
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs' --filter='!@multica/mobile'
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
65
.github/workflows/mobile-verify.yml
vendored
Normal file
65
.github/workflows/mobile-verify.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Mobile Verify
|
||||
|
||||
# Runs lint + typecheck for apps/mobile only, parallel to the main CI
|
||||
# workflow. Path-filtered so PRs that don't touch mobile (or anything
|
||||
# mobile transitively depends on) pay zero cost.
|
||||
#
|
||||
# Path scope rationale — mobile transitively depends on:
|
||||
# - apps/mobile/** — the app itself
|
||||
# - packages/core/** — mobile imports types AND pure functions
|
||||
# (agents, markdown, permissions, api/schemas, …),
|
||||
# not only @multica/core/types
|
||||
# - package.json / pnpm-lock.yaml — install graph
|
||||
# - pnpm-workspace.yaml — catalog versions
|
||||
# - turbo.json — turbo task pipeline
|
||||
#
|
||||
# Mobile has no vitest suite today; if one lands, add `test` to the turbo
|
||||
# task list below.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "packages/core/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- ".github/workflows/mobile-verify.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "packages/core/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- ".github/workflows/mobile-verify.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mobile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Type check and lint
|
||||
run: pnpm exec turbo typecheck lint --filter=@multica/mobile
|
||||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@@ -322,6 +322,47 @@ jobs:
|
||||
docker buildx imagetools inspect \
|
||||
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
|
||||
|
||||
helm-chart:
|
||||
needs: [verify, docker-backend-merge, docker-web-merge]
|
||||
if: github.repository_owner == 'multica-ai'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: release-helm-chart-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
CHART_DIR: deploy/helm/multica
|
||||
OCI_REGISTRY: oci://ghcr.io/${{ github.repository_owner }}/charts
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Helm
|
||||
uses: azure/setup-helm@v4
|
||||
|
||||
- name: Sync chart metadata with release tag
|
||||
env:
|
||||
TAG_NAME: ${{ needs.verify.outputs.tag_name }}
|
||||
run: |
|
||||
chart_version="${TAG_NAME#v}"
|
||||
sed -i -E "s/^version:.*/version: ${chart_version}/" "$CHART_DIR/Chart.yaml"
|
||||
sed -i -E "s/^appVersion:.*/appVersion: \"${TAG_NAME}\"/" "$CHART_DIR/Chart.yaml"
|
||||
echo "CHART_VERSION=${chart_version}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Lint chart
|
||||
run: helm lint "$CHART_DIR"
|
||||
|
||||
- name: Package chart
|
||||
run: helm package "$CHART_DIR" --destination .chart-packages
|
||||
|
||||
- name: Login to GHCR
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Push chart to GHCR
|
||||
run: helm push ".chart-packages/multica-${CHART_VERSION}.tgz" "$OCI_REGISTRY"
|
||||
|
||||
- name: Verify published chart
|
||||
run: helm show chart "$OCI_REGISTRY/multica" --version "$CHART_VERSION"
|
||||
|
||||
# Build the Desktop installers for Linux and Windows and upload them to
|
||||
# the GitHub Release that the `release` job above just published. macOS
|
||||
# Desktop continues to ship via the manual `release-desktop` skill so it
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -48,6 +48,8 @@ apps/web/test-results/
|
||||
|
||||
# context (agent workspace)
|
||||
.context
|
||||
.agent_context/
|
||||
.multica/
|
||||
|
||||
# local settings
|
||||
.claude/
|
||||
|
||||
@@ -114,7 +114,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -124,7 +124,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Antigravity). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
|
||||
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
Changes to `ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads all three from `/api/config` at runtime, so no web rebuild is needed. See [Advanced Configuration → Signup Controls](SELF_HOSTING_ADVANCED.md#signup-controls-optional) for the recommended sequence to lock down workspace creation.
|
||||
|
||||
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
|
||||
@@ -139,7 +139,7 @@ multica daemon status
|
||||
|
||||
## Kubernetes Deployment (Alternative)
|
||||
|
||||
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the Helm chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
|
||||
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the released OCI Helm chart at `oci://ghcr.io/multica-ai/charts/multica` or the source chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
|
||||
|
||||
The chart creates the following resources in the target namespace:
|
||||
|
||||
@@ -196,15 +196,29 @@ Leave optional values empty for now — you can fill them in later (see [Step 5
|
||||
### Step 4 — Install the chart
|
||||
|
||||
```bash
|
||||
helm install multica deploy/helm/multica -n multica
|
||||
helm install multica oci://ghcr.io/multica-ai/charts/multica \
|
||||
--version <chart-version> \
|
||||
-n multica
|
||||
```
|
||||
|
||||
To override defaults, copy `deploy/helm/multica/values.yaml`, edit it, and pass it with `-f`:
|
||||
Released chart versions strip the leading `v` from the Git tag. For example, release tag `v0.3.5` publishes chart version `0.3.5`; the chart defaults the backend and frontend image tags to `v0.3.5`.
|
||||
|
||||
To override defaults, export the chart values, edit them, and pass them with `-f`:
|
||||
|
||||
```bash
|
||||
cp deploy/helm/multica/values.yaml my-values.yaml
|
||||
helm show values oci://ghcr.io/multica-ai/charts/multica \
|
||||
--version <chart-version> > my-values.yaml
|
||||
# edit my-values.yaml — e.g. change ingress hosts, image tags, resource limits
|
||||
helm install multica deploy/helm/multica -n multica -f my-values.yaml
|
||||
helm install multica oci://ghcr.io/multica-ai/charts/multica \
|
||||
--version <chart-version> \
|
||||
-n multica \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
When developing from a checkout, use the local chart path instead:
|
||||
|
||||
```bash
|
||||
helm install multica deploy/helm/multica -n multica
|
||||
```
|
||||
|
||||
Watch the pods come up:
|
||||
@@ -245,14 +259,16 @@ The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend.
|
||||
- **Deterministic local/private testing:** set `backend.config.appEnv: development` in your values file and `MULTICA_DEV_VERIFICATION_CODE=888888` in the Secret, then `helm upgrade` and restart. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
```bash
|
||||
helm upgrade multica deploy/helm/multica -n multica \
|
||||
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
|
||||
--version <chart-version> \
|
||||
-n multica \
|
||||
-f my-values.yaml --set backend.config.appEnv=development
|
||||
kubectl -n multica patch secret multica-secrets --type=merge \
|
||||
-p '{"stringData":{"MULTICA_DEV_VERIFICATION_CODE":"888888"}}'
|
||||
kubectl -n multica rollout restart deploy/multica-backend
|
||||
```
|
||||
|
||||
`ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml`. After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
`ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml` (as `allowSignup`, `disableWorkspaceCreation`, and `googleClientId`). After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads all three from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
|
||||
@@ -270,13 +286,22 @@ Make sure the machine running the daemon has the same `/etc/hosts` (or DNS) entr
|
||||
|
||||
### Updating
|
||||
|
||||
To pull the latest images without changing the chart version:
|
||||
To pull the latest images without changing the chart version when your values still use the mutable `latest` image tag:
|
||||
|
||||
```bash
|
||||
kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend
|
||||
```
|
||||
|
||||
To pin a specific Multica release, set the image tags in your values file:
|
||||
To upgrade to a specific Multica release, upgrade to the matching chart version. The released chart defaults its app images to the matching Git tag:
|
||||
|
||||
```bash
|
||||
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
|
||||
--version <chart-version> \
|
||||
-n multica \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
If you need to override the app images independently from the chart version, set the image tags in your values file:
|
||||
|
||||
```yaml
|
||||
images:
|
||||
@@ -286,10 +311,13 @@ images:
|
||||
tag: v0.2.4
|
||||
```
|
||||
|
||||
Then upgrade:
|
||||
Then run the same upgrade command with `-f my-values.yaml`:
|
||||
|
||||
```bash
|
||||
helm upgrade multica deploy/helm/multica -n multica -f my-values.yaml
|
||||
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
|
||||
--version <chart-version> \
|
||||
-n multica \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
To roll back if an upgrade goes sideways:
|
||||
|
||||
@@ -67,8 +67,20 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
|
||||
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
|
||||
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
|
||||
| `DISABLE_WORKSPACE_CREATION` | Set to `true` to make `POST /api/workspaces` return 403 for every caller — users can only join workspaces they were invited to |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` and `DISABLE_WORKSPACE_CREATION` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
#### Locking down workspace creation
|
||||
|
||||
`ALLOW_SIGNUP=false` blocks new accounts from being created, but it does **not** block an already-signed-in user from creating another workspace via `POST /api/workspaces`. On a self-hosted instance where every issue/repo/agent must be visible to the platform admin, set `DISABLE_WORKSPACE_CREATION=true` to close that gap. The recommended bootstrap sequence is:
|
||||
|
||||
1. Start the instance with `DISABLE_WORKSPACE_CREATION=false` (the default).
|
||||
2. Sign in as the admin and create the shared workspace.
|
||||
3. Set `DISABLE_WORKSPACE_CREATION=true` and restart the backend. Optionally set `ALLOW_SIGNUP=false` at the same time if you also want to block new account creation.
|
||||
4. Going forward, additional users join via invitation only — the "Create workspace" affordance is hidden in the UI and any direct API call returns 403.
|
||||
|
||||
> Note: setting `ALLOW_SIGNUP=false` blocks **all** new account creation, including users who already have a pending invitation. If you need invited users to be able to sign up but not create their own workspaces, keep `ALLOW_SIGNUP=true` (optionally combined with `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS`) and only flip `DISABLE_WORKSPACE_CREATION=true`.
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { setupLocalDirectory } from "./local-directory";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { handleAppShortcut } from "./keyboard-shortcuts";
|
||||
@@ -460,6 +461,7 @@ if (!gotTheLock) {
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
setupDaemonManager(() => mainWindow);
|
||||
setupLocalDirectory(() => mainWindow);
|
||||
|
||||
// macOS: deep link arrives via open-url event
|
||||
app.on("open-url", (_event, url) => {
|
||||
|
||||
93
apps/desktop/src/main/local-directory.ts
Normal file
93
apps/desktop/src/main/local-directory.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ipcMain, dialog, BrowserWindow } from "electron";
|
||||
import { access, stat } from "fs/promises";
|
||||
import { constants as fsConstants } from "fs";
|
||||
import { basename, isAbsolute } from "path";
|
||||
|
||||
export interface PickDirectoryResult {
|
||||
ok: boolean;
|
||||
path?: string;
|
||||
basename?: string;
|
||||
/** Set when ok=false. "cancelled" = user dismissed; otherwise an error blurb. */
|
||||
reason?: "cancelled" | "no_window" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ValidateLocalDirectoryResult {
|
||||
ok: boolean;
|
||||
/** When ok=false, identifies which check failed so the renderer can render a
|
||||
* specific message without parsing free-form text. */
|
||||
reason?:
|
||||
| "not_absolute"
|
||||
| "not_found"
|
||||
| "not_a_directory"
|
||||
| "not_readable"
|
||||
| "not_writable"
|
||||
| "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function validateLocalDirectory(
|
||||
path: string,
|
||||
): Promise<ValidateLocalDirectoryResult> {
|
||||
if (!path || !isAbsolute(path)) {
|
||||
return { ok: false, reason: "not_absolute" };
|
||||
}
|
||||
try {
|
||||
const st = await stat(path);
|
||||
if (!st.isDirectory()) return { ok: false, reason: "not_a_directory" };
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") return { ok: false, reason: "not_found" };
|
||||
return { ok: false, reason: "error", error: errorMessage(err) };
|
||||
}
|
||||
try {
|
||||
await access(path, fsConstants.R_OK);
|
||||
} catch {
|
||||
return { ok: false, reason: "not_readable" };
|
||||
}
|
||||
try {
|
||||
await access(path, fsConstants.W_OK);
|
||||
} catch {
|
||||
return { ok: false, reason: "not_writable" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export function setupLocalDirectory(
|
||||
windowGetter: () => BrowserWindow | null,
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
"local-directory:pick",
|
||||
async (_event, defaultPath?: string): Promise<PickDirectoryResult> => {
|
||||
const win = windowGetter();
|
||||
if (!win) return { ok: false, reason: "no_window" };
|
||||
try {
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
// Multiple-selection is intentionally disabled — a project_resource
|
||||
// points at a single directory, and the create flow expects one
|
||||
// path per click. Multi-add would have to be a separate UX.
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
...(defaultPath ? { defaultPath } : {}),
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { ok: false, reason: "cancelled" };
|
||||
}
|
||||
const picked = result.filePaths[0];
|
||||
if (!picked) return { ok: false, reason: "cancelled" };
|
||||
return { ok: true, path: picked, basename: basename(picked) };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: "error", error: errorMessage(err) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"local-directory:validate",
|
||||
(_event, path: string): Promise<ValidateLocalDirectoryResult> =>
|
||||
validateLocalDirectory(path),
|
||||
);
|
||||
}
|
||||
26
apps/desktop/src/preload/index.d.ts
vendored
26
apps/desktop/src/preload/index.d.ts
vendored
@@ -45,6 +45,32 @@ interface DesktopAPI {
|
||||
) => () => void;
|
||||
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
|
||||
/** Open the OS folder picker and return the chosen absolute path.
|
||||
* Used by the Project settings "Add local directory" flow. */
|
||||
pickDirectory: (
|
||||
defaultPath?: string,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
path?: string;
|
||||
basename?: string;
|
||||
reason?: "cancelled" | "no_window" | "error";
|
||||
error?: string;
|
||||
}>;
|
||||
/** Validate that a path is an existing readable+writable directory.
|
||||
* Mirrors the daemon's runtime check so the user sees errors before submit. */
|
||||
validateLocalDirectory: (
|
||||
path: string,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
reason?:
|
||||
| "not_absolute"
|
||||
| "not_found"
|
||||
| "not_a_directory"
|
||||
| "not_readable"
|
||||
| "not_writable"
|
||||
| "error";
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -156,6 +156,12 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
/** Open the OS folder picker and return the chosen absolute path. */
|
||||
pickDirectory: (defaultPath?: string) =>
|
||||
ipcRenderer.invoke("local-directory:pick", defaultPath),
|
||||
/** Validate that a path is an existing readable+writable directory. */
|
||||
validateLocalDirectory: (path: string) =>
|
||||
ipcRenderer.invoke("local-directory:validate", path),
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -34,6 +34,7 @@ function SidebarTopBar() {
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Go back"
|
||||
@@ -42,6 +43,7 @@ function SidebarTopBar() {
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Go forward"
|
||||
|
||||
@@ -124,6 +124,7 @@ function SortableTabItem({
|
||||
|
||||
const tabButton = (
|
||||
<button
|
||||
type="button"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
@@ -221,6 +222,7 @@ function NewTabButton() {
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
|
||||
|
||||
@@ -26,6 +26,7 @@ export function UpdateNotification() {
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
@@ -43,12 +44,14 @@ export function UpdateNotification() {
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<button
|
||||
type="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"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
type="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"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
use,
|
||||
type AnchorHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -30,7 +30,7 @@ export function DocsLocaleProvider({
|
||||
}
|
||||
|
||||
export function useDocsLocale(): Lang {
|
||||
return useContext(DocsLocaleContext);
|
||||
return use(DocsLocaleContext);
|
||||
}
|
||||
|
||||
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same
|
||||
|
||||
@@ -21,7 +21,7 @@ The form has only two required fields: **name** (unique within the workspace) an
|
||||
|
||||
## Pick an AI coding tool
|
||||
|
||||
Each runtime is backed by a specific AI coding tool. Multica supports 11 of them. The most common choices:
|
||||
Each runtime is backed by a specific AI coding tool. Multica supports 12 of them. The most common choices:
|
||||
|
||||
| Tool | Good for |
|
||||
|---|---|
|
||||
@@ -31,7 +31,7 @@ Each runtime is backed by a specific AI coding tool. Multica supports 11 of them
|
||||
| **Copilot** | Teams leveraging their GitHub account entitlements |
|
||||
| **Gemini** | Users in the Google ecosystem |
|
||||
|
||||
The other six (Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
|
||||
The other seven (Antigravity, Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
|
||||
|
||||
## Writing system instructions
|
||||
|
||||
@@ -123,5 +123,5 @@ Archived agents can't be assigned new tasks.
|
||||
## Next steps
|
||||
|
||||
- [Skills](/skills) — attach knowledge packs to an agent
|
||||
- [AI coding tools comparison](/providers) — full capability matrix across all 11 tools
|
||||
- [AI coding tools comparison](/providers) — full capability matrix across all 12 tools
|
||||
- [Assigning issues to agents](/assigning-issues) — put your new agent to work
|
||||
|
||||
@@ -21,7 +21,7 @@ multica agent create
|
||||
|
||||
## 选一款 AI 编程工具
|
||||
|
||||
运行时背后是一款具体的 AI 编程工具。Multica 支持 11 款,最常用的几款:
|
||||
运行时背后是一款具体的 AI 编程工具。Multica 支持 12 款,最常用的几款:
|
||||
|
||||
| 工具 | 适合 |
|
||||
|---|---|
|
||||
@@ -31,7 +31,7 @@ multica agent create
|
||||
| **Copilot** | 用 GitHub 账号权益的团队 |
|
||||
| **Gemini** | Google 生态用户 |
|
||||
|
||||
另外 6 款(Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
|
||||
另外 7 款(Antigravity、Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
|
||||
|
||||
## 写系统指令
|
||||
|
||||
@@ -123,5 +123,5 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
|
||||
## 下一步
|
||||
|
||||
- [Skills](/skills) —— 给智能体挂专业知识包
|
||||
- [AI 编程工具对照](/providers) —— 11 款工具的完整能力差别
|
||||
- [AI 编程工具对照](/providers) —— 12 款工具的完整能力差别
|
||||
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来
|
||||
|
||||
@@ -29,18 +29,39 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
|
||||
|
||||
### 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.
|
||||
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set — if `SMTP_HOST` is non-empty the server always goes through SMTP, even if `RESEND_API_KEY` is also configured, so verification and invite mail never leaves the internal network.
|
||||
|
||||
The SMTP path supports the three relay modes most on-premise mail servers (notably Microsoft Exchange's receive connectors) expose:
|
||||
|
||||
| Mode | Port | Auth | TLS |
|
||||
|---|---|---|---|
|
||||
| Anonymous internal relay | `25` | none — submission is trusted by IP / subnet | none on the wire (internal segment only) |
|
||||
| Authenticated submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, upgraded automatically |
|
||||
| Implicit TLS (SMTPS) | `465` | — | **not supported yet** — use port 25 or 587 |
|
||||
|
||||
**Anonymous Exchange relay on port 25** — the typical "internal SMTP relay" / Exchange anonymous receive connector that accepts mail from a trusted subnet without credentials:
|
||||
|
||||
```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
|
||||
SMTP_HOST=exchange.internal.example.com
|
||||
SMTP_PORT=25
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_INSECURE=false
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
|
||||
**Authenticated submission on port 587** — for relays that require a service account; STARTTLS is upgraded automatically when the server advertises it:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=multica
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
At startup the server prints which provider it picked — for example `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com` (or `Resend API` / `DEV mode`). The password is never logged. If you don't see the SMTP line after restart, `SMTP_HOST` didn't reach the process — check the container env (`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`).
|
||||
|
||||
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
|
||||
|
||||
@@ -29,18 +29,39 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
### Option B:SMTP relay(内网/自部署)
|
||||
|
||||
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`:只要 `SMTP_HOST` 非空,server 一律走 SMTP 路径,即便 `RESEND_API_KEY` 也配着,验证码和邀请邮件也不会经过 Resend 外发出内网。
|
||||
|
||||
SMTP 路径覆盖大多数本地邮件服务器(特别是 Microsoft Exchange 的 receive connector)暴露的三种 relay 模式:
|
||||
|
||||
| 模式 | 端口 | 认证 | TLS |
|
||||
|---|---|---|---|
|
||||
| 匿名内部 relay | `25` | 无 —— 按 IP / 子网信任 | 链路上无 TLS(仅限内网段) |
|
||||
| 认证提交(submission) | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS,自动升级 |
|
||||
| 隐式 TLS(SMTPS) | `465` | —— | **暂不支持** —— 请用 25 或 587 |
|
||||
|
||||
**匿名 Exchange relay,端口 25** —— 经典的 "internal SMTP relay" / Exchange 匿名 receive connector,按可信子网放行,不要求凭据:
|
||||
|
||||
```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
|
||||
SMTP_HOST=exchange.internal.example.com
|
||||
SMTP_PORT=25
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_INSECURE=false
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465(SMTPS / 隐式 TLS),请使用 25 或 587。
|
||||
**认证提交,端口 587** —— 需要 service account 的 relay;服务端 advertise STARTTLS 时会自动升级:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=multica
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
启动时 server 会打印当前选择的 provider,比如 `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com`(或 `Resend API` / `DEV mode`),密码不会出现在日志里。重启后没看到 SMTP 这行,说明 `SMTP_HOST` 没进到进程,确认下容器环境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)。
|
||||
|
||||
**两种都不配**:server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
|
||||
@@ -99,6 +99,126 @@ 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.
|
||||
|
||||
### Event filters
|
||||
|
||||
A new webhook trigger fires on every inbound POST, which is fine for a
|
||||
single-purpose URL but noisy for sources that fan out many event types
|
||||
(GitHub being the obvious one — a single repo webhook can deliver
|
||||
`push`, `pull_request`, `workflow_run`, `check_suite`, and more). The
|
||||
**Event filters** section on a webhook trigger lets you restrict which
|
||||
events actually dispatch a run; everything else is recorded in delivery
|
||||
history with `status = ignored` and `reason = event_filtered`, and no
|
||||
run or issue is created.
|
||||
|
||||
Each row is one rule: an **event name** plus an optional
|
||||
comma-separated **actions** list. Multica allows a webhook if **any**
|
||||
row matches; leave the section empty to accept everything (the
|
||||
pre-filter behavior).
|
||||
|
||||
Examples:
|
||||
|
||||
| Event name | Actions | Matches |
|
||||
| -------------- | ------------------- | ------------------------------------------------------------------------ |
|
||||
| `workflow_run` | `completed, failed` | `workflow_run` events with `action: completed` or `action: failed` only |
|
||||
| `workflow_run` | _(empty)_ | every `workflow_run` event, regardless of action |
|
||||
| `push` | _(empty)_ | every `push` event |
|
||||
|
||||
#### Where the event name and action come from
|
||||
|
||||
Multica derives the `event` name and `action` from the inbound request
|
||||
in this order — **the first match wins**.
|
||||
|
||||
**1. Body envelope.** If the body is a JSON object with a string
|
||||
`event` field, that value is the event name directly. An optional
|
||||
`eventPayload` object then supplies action candidates from its
|
||||
`action` / `state` / `conclusion` / `status` fields.
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
|
||||
# inferred: event = trigger, action candidate = true
|
||||
```
|
||||
|
||||
**2. Headers.** When no body envelope is present, Multica reads the
|
||||
following well-known provider headers:
|
||||
|
||||
- `X-GitHub-Event: <event>` — combined with the top-level body
|
||||
`action` field (when present) to form `github.<event>.<action>`.
|
||||
- `X-Gitlab-Event: <event>` — becomes `gitlab.<event>`.
|
||||
- `X-Event-Type: <event>` — passed through verbatim.
|
||||
|
||||
```bash
|
||||
# GitHub-style: header gives the event name, body gives the action.
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-GitHub-Event: workflow_run' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"completed"}'
|
||||
# inferred: event = github.workflow_run.completed
|
||||
# → matches a filter row of workflow_run / completed
|
||||
|
||||
# Generic event-type header — no body fields needed.
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-Event-Type: trigger.true' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{}'
|
||||
# inferred: event = trigger.true → matches trigger / true
|
||||
```
|
||||
|
||||
**3. Body fallback.** If neither a body envelope nor a known header is
|
||||
present, Multica falls back to top-level body string fields in this
|
||||
order: `event` → `type` → `action`.
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"type":"trigger","action":"true"}'
|
||||
# inferred: event = trigger (from `type`), action candidate = true
|
||||
```
|
||||
|
||||
**4. Default.** If nothing above matches, the event is
|
||||
`webhook.received` and there are no action candidates.
|
||||
|
||||
**Action candidates, in full.** Once the event is determined, Multica
|
||||
considers every value below as a possible action match:
|
||||
|
||||
- The event-name suffix, when the event has the form
|
||||
`provider.event.<action>` (e.g. `github.workflow_run.completed` →
|
||||
`completed`).
|
||||
- The body fields `action`, `state`, `conclusion`, and `status` —
|
||||
**only when they are JSON strings**. A boolean (`{"action": true}`)
|
||||
or a number does not qualify, so a filter expecting
|
||||
`event=trigger, action=true` will never match a body of
|
||||
`{"trigger": true}` because `true` is a bool, not a string.
|
||||
|
||||
**Common gotcha.** A filter row like `Event name: trigger` /
|
||||
`Actions: true` does **not** mean "fire when the body has
|
||||
`trigger: true`" — Event filters match the *inferred event and
|
||||
action*, not arbitrary body fields. Send `trigger.true` via
|
||||
`X-Event-Type` (or use the body envelope shown above) to hit it.
|
||||
Surrounding whitespace in saved filter rows (`" workflow_run "`) is
|
||||
stored verbatim and will never match — trim before saving.
|
||||
|
||||
#### Quick test
|
||||
|
||||
Once a filter is configured, you can confirm both branches with `curl`:
|
||||
|
||||
```bash
|
||||
# Allowed — header drives event=workflow_run, body drives action=completed
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-GitHub-Event: workflow_run' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"completed"}'
|
||||
# → 200 {"status":"accepted", ...}
|
||||
|
||||
# Filtered — same event, action not in allowlist
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-GitHub-Event: workflow_run' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"in_progress"}'
|
||||
# → 200 {"status":"ignored","reason":"event_filtered"}
|
||||
```
|
||||
|
||||
### URL is a bearer secret
|
||||
|
||||
The generated URL **is** the credential. Anyone with it can fire the
|
||||
|
||||
@@ -98,6 +98,116 @@ curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
|
||||
表单编码的 webhook payload 在 v1 里不接受。
|
||||
|
||||
### 事件过滤
|
||||
|
||||
新建的 webhook 触发器对每一条入站 POST 都会触发,这在单一用途的 URL
|
||||
上没问题,但对那种会扇出很多事件类型的来源(典型就是 GitHub——
|
||||
一个仓库 webhook 就会同时下发 `push`、`pull_request`、`workflow_run`、
|
||||
`check_suite` 等等)就会很吵。webhook 触发器上的**事件过滤**区块用来
|
||||
限制哪些事件真正派发一次 run;其它的只记录到投递历史里,
|
||||
`status = ignored`、`reason = event_filtered`,不会建任何 issue 或 run。
|
||||
|
||||
每一行是一条规则:一个**事件名**加可选的、逗号分隔的 **action** 列表。
|
||||
**任意一行命中**即放行;区块留空则接受所有事件(即过滤前的行为)。
|
||||
|
||||
例子:
|
||||
|
||||
| 事件名 | Actions | 命中范围 |
|
||||
| ---------------- | ------------------- | ------------------------------------------------------------------------ |
|
||||
| `workflow_run` | `completed, failed` | 只有 `action` 为 `completed` 或 `failed` 的 `workflow_run` 事件 |
|
||||
| `workflow_run` | _(留空)_ | 所有 `workflow_run` 事件,不限 action |
|
||||
| `push` | _(留空)_ | 所有 `push` 事件 |
|
||||
|
||||
#### 事件名和 action 从哪来
|
||||
|
||||
Multica 按下面的顺序从入站请求里推断 `event` 和 `action`,**先命中先用**。
|
||||
|
||||
**1. Body envelope。** 如果 body 是一个 JSON 对象、且带字符串字段
|
||||
`event`,就直接用它作为事件名。可选的 `eventPayload` 对象再
|
||||
从自己的 `action` / `state` / `conclusion` / `status` 字段里提供
|
||||
action 候选。
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
|
||||
# 推断结果:event = trigger,action 候选 = true
|
||||
```
|
||||
|
||||
**2. 请求头。** 没有 body envelope 时按以下头部识别:
|
||||
|
||||
- `X-GitHub-Event: <event>` —— 结合 body 顶层的 `action` 字段
|
||||
(如果有),拼成 `github.<event>.<action>`。
|
||||
- `X-Gitlab-Event: <event>` —— 拼成 `gitlab.<event>`。
|
||||
- `X-Event-Type: <event>` —— 原样使用。
|
||||
|
||||
```bash
|
||||
# GitHub 风格:事件名来自 header,action 来自 body。
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-GitHub-Event: workflow_run' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"completed"}'
|
||||
# 推断结果:event = github.workflow_run.completed
|
||||
# → 命中 workflow_run / completed 的过滤规则
|
||||
|
||||
# 通用 event-type 头部 —— 不需要任何 body 字段。
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-Event-Type: trigger.true' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{}'
|
||||
# 推断结果:event = trigger.true → 命中 trigger / true
|
||||
```
|
||||
|
||||
**3. Body 兜底。** Body envelope 和上面的 header 都没有时,
|
||||
从 body 顶层字符串字段里依次找:`event` → `type` → `action`。
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"type":"trigger","action":"true"}'
|
||||
# 推断结果:event = trigger(取自 `type`),action 候选 = true
|
||||
```
|
||||
|
||||
**4. 默认值。** 以上都没命中时,事件名取 `webhook.received`,
|
||||
没有 action 候选。
|
||||
|
||||
**action 候选的完整清单。** 事件名确定后,下面这些值都会被列为
|
||||
可能的 action:
|
||||
|
||||
- 事件名后缀,当事件形如 `provider.event.<action>` 时
|
||||
(例如 `github.workflow_run.completed` → `completed`)。
|
||||
- body 里 `action`、`state`、`conclusion`、`status` 这四个字段——
|
||||
**必须是 JSON 字符串**。布尔(`{"action": true}`)或数字都不算
|
||||
候选,所以 `event=trigger, action=true` 的过滤规则**永远命中不了**
|
||||
`{"trigger": true}` 这种 body,因为 `true` 是 bool 不是字符串。
|
||||
|
||||
**常见误区。** 一条 `Event name: trigger` / `Actions: true` 的规则
|
||||
**不是**「body 里出现 `trigger: true` 就放行」的意思——事件过滤匹配的是
|
||||
**推断出来的事件和 action**,不是任意 body 字段。要命中它,请用
|
||||
`X-Event-Type` 头发送 `trigger.true`,或者用上面的 body envelope。
|
||||
保存时带空格的值(例如 `" workflow_run "`)会被原样保存,但永远命中
|
||||
不了——保存前请先 trim。
|
||||
|
||||
#### 快速验证
|
||||
|
||||
配好过滤后,可以用 `curl` 同时验证「命中」和「被过滤」两条路径:
|
||||
|
||||
```bash
|
||||
# 命中 —— 请求头给出 event=workflow_run,body 给出 action=completed
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-GitHub-Event: workflow_run' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"completed"}'
|
||||
# → 200 {"status":"accepted", ...}
|
||||
|
||||
# 被过滤 —— 同样的事件,但 action 不在白名单
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H 'X-GitHub-Event: workflow_run' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"action":"in_progress"}'
|
||||
# → 200 {"status":"ignored","reason":"event_filtered"}
|
||||
```
|
||||
|
||||
### URL 即 bearer secret
|
||||
|
||||
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
|
||||
|
||||
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
|
||||
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
|
||||
|
||||
## 1. Create an account
|
||||
|
||||
@@ -114,6 +114,6 @@ The web UI updates in **real time** (via WebSocket) — no refresh needed.
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean
|
||||
- [Tasks](/tasks) — task lifecycle and retry rules
|
||||
- [AI coding tools compared](/providers) — capability differences across the 11 tools
|
||||
- [AI coding tools compared](/providers) — capability differences across the 12 tools
|
||||
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
|
||||
- [Self-host quickstart](/self-host-quickstart) — run your own backend
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
|
||||
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
|
||||
## 1. 注册账号
|
||||
|
||||
@@ -114,6 +114,6 @@ Web 界面会**实时**(通过 WebSocket)显示进度——不需要刷新
|
||||
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
|
||||
- [执行任务](/tasks) —— 任务生命周期、重试规则
|
||||
- [AI 编程工具对照](/providers) —— 11 款工具的能力差异
|
||||
- [AI 编程工具对照](/providers) —— 12 款工具的能力差异
|
||||
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
|
||||
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
On startup it does four things:
|
||||
|
||||
1. Reads the credentials saved when you logged in
|
||||
2. Detects AI coding tools installed on your `PATH` (11 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
2. Detects AI coding tools installed on your `PATH` (12 built-in: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
3. Registers itself with the server, along with a runtime for each detected tool
|
||||
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
|
||||
|
||||
@@ -108,4 +108,4 @@ More scenarios in [Troubleshooting](/troubleshooting).
|
||||
## Next
|
||||
|
||||
- [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up
|
||||
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools
|
||||
- [Providers Matrix](/providers) — capability differences across the 12 AI coding tools
|
||||
|
||||
@@ -21,7 +21,7 @@ multica daemon start
|
||||
启动后它会做四件事:
|
||||
|
||||
1. 读取你登录时保存的凭证
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
|
||||
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
|
||||
|
||||
@@ -108,4 +108,4 @@ Multica 对并发有两层限额:
|
||||
## 下一步
|
||||
|
||||
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
|
||||
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照
|
||||
- [Providers Matrix](/providers) —— 12 款 AI 编程工具的能力差异对照
|
||||
|
||||
@@ -128,6 +128,22 @@ 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**.
|
||||
|
||||
## Locking down workspace creation
|
||||
|
||||
`ALLOW_SIGNUP=false` blocks new accounts, but it does **not** block an already-signed-in user from creating another workspace via `POST /api/workspaces`. On a self-hosted instance where every issue, repo, and agent must be visible to the platform admin, set `DISABLE_WORKSPACE_CREATION=true` to close that gap.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `DISABLE_WORKSPACE_CREATION` | `false` | When `true`, every call to `POST /api/workspaces` returns `403 workspace creation is disabled for this instance`. The web UI hides every "Create workspace" affordance via `/api/config`. There is no role/owner exception — the gate is global per instance |
|
||||
|
||||
Recommended bootstrap sequence:
|
||||
|
||||
1. Start the instance with `DISABLE_WORKSPACE_CREATION` unset (the default).
|
||||
2. Sign in as the admin and create the shared workspace.
|
||||
3. Set `DISABLE_WORKSPACE_CREATION=true` and restart the backend. From this point on, users join via invitation only.
|
||||
|
||||
If you also want to keep `ALLOW_SIGNUP=true` so invited users can finish signup with their first verification code, combine `DISABLE_WORKSPACE_CREATION=true` with `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS` to scope which addresses can sign up. Setting `ALLOW_SIGNUP=false` will additionally block pending invitees from creating their account at all — useful only on instances where every member already has a Multica account.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -128,6 +128,22 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
|
||||
|
||||
## 锁死工作区创建
|
||||
|
||||
`ALLOW_SIGNUP=false` 能挡住新注册,但**挡不住**已经登录的用户继续 `POST /api/workspaces` 自助开新工作区。在希望平台管理员能看到全部 issue / 仓库 / 智能体的自部署实例里,把 `DISABLE_WORKSPACE_CREATION=true` 打开以堵上这个口子。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `DISABLE_WORKSPACE_CREATION` | `false` | 设为 `true` 时,对 `POST /api/workspaces` 的任何调用都返回 `403 workspace creation is disabled for this instance`。Web UI 通过 `/api/config` 隐藏所有「创建工作区」入口。该开关是实例级的,没有 owner / admin 例外 |
|
||||
|
||||
推荐的开服流程:
|
||||
|
||||
1. 启动实例时保持 `DISABLE_WORKSPACE_CREATION` 未设置(默认)。
|
||||
2. 管理员登录并创建共享工作区。
|
||||
3. 把 `DISABLE_WORKSPACE_CREATION=true` 设上并重启 backend。之后新用户只能通过邀请加入。
|
||||
|
||||
如果还想让被邀请的新用户能完成首次注册,保留 `ALLOW_SIGNUP=true`(必要时用 `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS` 限定可注册邮箱),只把 `DISABLE_WORKSPACE_CREATION=true` 打开即可;如果同时设 `ALLOW_SIGNUP=false`,连有 pending invite 的邮箱都无法完成首次注册,仅适合所有成员都已有 Multica 账号的实例。
|
||||
|
||||
## 速率限制(可选 Redis)
|
||||
|
||||
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**(fail-open),后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro
|
||||
|
||||
- **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks.
|
||||
- **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds.
|
||||
- **AI coding tools** — one of the eleven (or several in parallel): [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
|
||||
- **AI coding tools** — one of the twelve (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
|
||||
|
||||
Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
|
||||
|
||||
- **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub,把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。
|
||||
- **守护进程**(daemon)——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server,开始每 3 秒领一次任务、每 15 秒发一次心跳。
|
||||
- **AI 编程工具**——[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 11 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
- **AI 编程工具**——[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 12 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
|
||||
|
||||
工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用;Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica.
|
||||
|
||||
Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model:
|
||||
|
||||
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Eleven are built in today: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
|
||||
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Twelve are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
|
||||
|
||||
<Callout type="info">
|
||||
**Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified.
|
||||
|
||||
@@ -13,7 +13,7 @@ Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在
|
||||
|
||||
智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式:
|
||||
|
||||
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
|
||||
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
|
||||
|
||||
<Callout type="info">
|
||||
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
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.
|
||||
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 12 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.
|
||||
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 12 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:
|
||||
|
||||
@@ -31,9 +31,9 @@ multica daemon restart
|
||||
|
||||
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
|
||||
|
||||
## The 11 supported tools
|
||||
## The 12 supported tools
|
||||
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 12.
|
||||
|
||||
### Claude Code (Anthropic)
|
||||
|
||||
@@ -147,9 +147,20 @@ Minimalist. **Session resumption is unusual** — the resume id is the path to a
|
||||
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
### Antigravity (Google)
|
||||
|
||||
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `agy` |
|
||||
| Install | Follow the official guide at [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview). The CLI ships pre-built — run `agy install` once to wire up PATH and shell aliases. |
|
||||
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
|
||||
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text. |
|
||||
|
||||
## 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).
|
||||
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`, `which agy`). 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.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: 安装一个 Agent 运行时
|
||||
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
|
||||
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 12 款工具,让守护进程能扫到。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 Multica 里,一个**运行时**(runtime)就是你机器上的守护进程,配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
|
||||
在 Multica 里,一个**运行时**(runtime)就是你机器上的守护进程,配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 12 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
|
||||
|
||||
这一页是装机的入口,和它配套的是:
|
||||
|
||||
@@ -31,13 +31,13 @@ multica daemon restart
|
||||
|
||||
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
|
||||
|
||||
## 11 款支持的工具
|
||||
## 12 款支持的工具
|
||||
|
||||
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
|
||||
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 12 个全装。
|
||||
|
||||
### Claude Code(Anthropic)
|
||||
|
||||
集成最完整的一款。会话续接好用,MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
|
||||
集成最完整的一款。会话续接好用,MCP 好用,而且 **12 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -147,9 +147,20 @@ ACP 协议 agent(和 Kimi 共享传输层)。会话续接可用。Skill 注
|
||||
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
### Antigravity(Google)
|
||||
|
||||
Google 的 Antigravity CLI(`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `agy` |
|
||||
| 安装 | 看官方指引 [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview)。CLI 是预编译的,跑一次 `agy install` 配好 PATH 和 shell 别名即可。 |
|
||||
| 认证 | 交互式跑一次 `agy` 走 Google 账号登录流程;或者通过 Antigravity 桌面端登录——CLI 会复用 GUI 写入 keyring 的凭据。 |
|
||||
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 思考过程和最终回复都会作为 text 消息送回 Multica。 |
|
||||
|
||||
## 装完之后
|
||||
|
||||
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
|
||||
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`、`which agy`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
|
||||
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
|
||||
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
|
||||
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"issues",
|
||||
"projects",
|
||||
"comments",
|
||||
"project-resources",
|
||||
"---智能体---",
|
||||
"agents",
|
||||
"agents-create",
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
---
|
||||
title: Project Resources
|
||||
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
|
||||
description: Attach typed pointers (Git repos, local directories, more later) to a project so agents can pick them up as scoped context.
|
||||
---
|
||||
|
||||
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
|
||||
A **Project Resource** is a typed pointer — a Git repo URL, a path on your own machine, a Notion page tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
|
||||
|
||||
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
|
||||
The result: the agent knows which repo to check out (or which local directory to work in), and which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
|
||||
|
||||
## Mental model
|
||||
|
||||
A project is no longer just a label. It is a small **resource container**:
|
||||
|
||||
- A project has 0..N **resources**.
|
||||
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
|
||||
- A resource has a `resource_type` (e.g. `github_repo`, `local_directory`) and a `resource_ref` (a JSON payload typed by `resource_type`).
|
||||
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
|
||||
|
||||
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
|
||||
|
||||
## Today: `github_repo`
|
||||
Today two resource types ship: [`github_repo`](#resource-type-github_repo) (clone-per-task into an isolated worktree) and [`local_directory`](#resource-type-local_directory) (run directly inside a folder on a specific daemon's machine).
|
||||
|
||||
The first resource type ships ready to use:
|
||||
## Resource type: `github_repo`
|
||||
|
||||
The default resource type — checked out per task into an isolated worktree:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -33,6 +35,122 @@ The first resource type ships ready to use:
|
||||
|
||||
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
|
||||
|
||||
## Resource type: `local_directory`
|
||||
|
||||
For repos that can't reasonably be re-cloned per task — multi-gigabyte game checkouts, large monorepos, or any project where the worktree-per-task model is painful — a project can instead point at an **existing directory on a specific [daemon](/daemon-runtimes)'s machine**. The agent runs **directly inside that folder**, with no clone, no copy, and no worktree.
|
||||
|
||||
```json
|
||||
{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": {
|
||||
"local_path": "/Users/me/code/big-game",
|
||||
"daemon_id": "0001234e-…",
|
||||
"label": "main checkout"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The trade-off vs. `github_repo` is intentional: only the bound daemon can pick up tasks against the directory, and tasks on the same directory run **serially** instead of in parallel. In exchange you keep your existing checkout, your existing branch, your existing dirty state — Multica never re-clones it.
|
||||
|
||||
### When to pick `local_directory` over `github_repo`
|
||||
|
||||
| Concern | `github_repo` (worktree) | `local_directory` |
|
||||
| --- | --- | --- |
|
||||
| Checkout cost per task | Fresh clone + worktree | None — agent runs in place |
|
||||
| Concurrency on the same repo | Many tasks in parallel | One at a time per directory |
|
||||
| Branch / dirty state | Each task gets a fresh branch from the default | Whatever the directory currently has |
|
||||
| Where it can run | Any daemon | Exactly one daemon (the one bound) |
|
||||
| Disk footprint | One worktree per task | Zero overhead — your existing folder |
|
||||
|
||||
Pick `local_directory` when **either** of these matches:
|
||||
|
||||
1. **Re-cloning is prohibitively expensive** — a multi-gigabyte game checkout, a monorepo with heavy LFS assets, or anything where the per-task `git clone` would dominate the actual work. You trade concurrency for a clone-free run.
|
||||
2. **Your changes are fine-grained and you want to review them locally as they happen** — you're iterating on a single component, you want to flip between the agent's edits and your editor every few minutes, and you'd rather have your existing checkout be the source of truth than a per-task worktree you have to dig out of `~/multica_workspaces/`.
|
||||
|
||||
The trade-off you accept in both cases is the same: **this version ships no file-level write lock.** The per-directory serial gate (one task at a time on the same folder) is the only protection against agents in two different issues touching the same files at the same time. If you point two issues' agents at the same `local_directory`, their tasks queue rather than parallelise — that's by design. If you need real parallelism on the same codebase, stay on `github_repo`.
|
||||
|
||||
### Attaching a local directory
|
||||
|
||||
The folder picker lives in the **Desktop app** only — the web app has no way to read OS paths, so the "Add local directory" button is hidden there. On Desktop:
|
||||
|
||||
1. Open the project → **Resources** panel.
|
||||
2. Click **Add local directory**. A native folder picker opens.
|
||||
3. Pick the folder. The path is bound to **the daemon currently registered by this Desktop install** — the resource record stores both the path and that daemon's ID.
|
||||
|
||||
On Desktop the button stays visible but is **disabled with a hint** when the daemon on this machine is offline, or when the project already has a `local_directory` bound to this daemon — so you can see *why* it's unavailable. (On the web app the button is hidden outright, since there's no folder picker available there at all.) To bind another machine's directory, install Desktop on that machine and add the resource from there.
|
||||
|
||||
From the CLI (works on web-only environments too, as long as you supply the daemon ID yourself):
|
||||
|
||||
```bash
|
||||
multica project resource add <project-id> \
|
||||
--type local_directory \
|
||||
--local-path /Users/me/code/big-game \
|
||||
--daemon-id <daemon-uuid> \
|
||||
--ref-label "main checkout" # optional
|
||||
|
||||
multica project resource update <project-id> <resource-id> \
|
||||
--local-path /Users/me/code/big-game-new
|
||||
```
|
||||
|
||||
`--daemon-id` comes from `multica daemon list`. The CLI also accepts the generic `--ref '<json>'` escape hatch if you'd rather pass the payload directly.
|
||||
|
||||
### Path rules
|
||||
|
||||
The path you attach must clear both an attach-time and a per-task validation. Both are enforced by the daemon that owns the resource — the server only stores the JSON. A path that breaks any rule fails the task with a typed error and leaves your directory untouched:
|
||||
|
||||
- Must be **absolute**.
|
||||
- Must **exist** and be a **directory** (not a file, symlink to a file, or device node).
|
||||
- Must be **readable and writable** by the daemon process.
|
||||
- Cannot be a system root or an entire user profile — `/`, `/Users`, `/home`, `/root`, `/etc`, `/tmp`, `/var`, `/usr`, `/opt`, `/Users/Shared`, your own `$HOME`, any Windows drive root (`C:\`, `D:\`, …), or `C:\Users` / `C:\ProgramData` / `C:\Program Files` / `C:\Program Files (x86)` / `C:\Windows`.
|
||||
- A symlink that resolves to any of the above is rejected, and so is the canonical form of an OS-aliased path (e.g. on macOS, typing `/private/tmp` is rejected the same way as `/tmp`).
|
||||
|
||||
The blacklist is intentionally aggressive — picking your home directory would put Multica's runtime files at the root of your account, which is never what you want. Pick a sub-folder (typically your actual project checkout) instead.
|
||||
|
||||
### One per (project, daemon)
|
||||
|
||||
A project may hold **at most one `local_directory` per daemon**. Attempting to add a second one on the same daemon returns a `409` from the API; the Desktop button hides itself when the limit is already reached and surfaces a tooltip explaining why.
|
||||
|
||||
Different daemons are independent — a shared project can have one `local_directory` per teammate's machine, each binding the same project to a different folder on a different host. When the daemon claims a task, it picks the row that matches its own ID and ignores the rest.
|
||||
|
||||
### Mixing resource types, and multiple `local_directory` resources
|
||||
|
||||
Two cross-resource configurations show up in practice:
|
||||
|
||||
- **`github_repo` + `local_directory` on the same project.** On the daemon that has a matching `local_directory` binding, the local directory **takes precedence**: the agent runs in your folder, and the daemon does not create or use a `github_repo` worktree for that task. (The per-workspace repo cache may still sync as usual — that's a background behaviour unrelated to this task's working tree.) The `github_repo` URL still appears in `.multica/project/resources.json` and in the agent's `## Repositories` section for reference — but the working tree the agent edits is your local one, not a worktree. On a daemon that has **no** `local_directory` row for this project (different machine, or before that teammate attached one), the task falls back to the usual `github_repo` worktree flow. Effectively the local directory is a per-daemon override of the worktree path.
|
||||
- **Two `local_directory` resources on the same project.** Because each `local_directory` is bound to exactly one daemon, this only happens across two different machines (the API rejects two on the same daemon at attach time, see above). Tasks are routed by the agent's runtime assignment, not by which daemon has a local directory: a task lands on the daemon that owns the receiving agent's runtime, that daemon picks the `local_directory` row matching its own ID, and ignores the rest. There is no load-balancing — if you want a specific machine to run a task, dispatch the agent that's bound to that machine's runtime.
|
||||
|
||||
A daemon that has no `local_directory` row for a project that has one bound elsewhere is **not** blocked — its tasks simply proceed via the project's other resources (typically the `github_repo` fallback). The `local_directory` only matters for the daemon it's bound to.
|
||||
|
||||
### Running tasks against a local directory
|
||||
|
||||
When a task is dispatched on an issue whose project has a `local_directory` bound to the receiving daemon, the daemon:
|
||||
|
||||
1. Re-validates the path (rules above).
|
||||
2. Acquires a per-directory lock keyed on the symlink-resolved real path — so two routes to the same folder (one via a symlink, one direct) still serialise.
|
||||
3. Writes the agent's `CLAUDE.md` / `AGENTS.md` (and `.multica/project/resources.json`) **into the user's directory**. The agent works there, just as if you'd opened the folder yourself.
|
||||
4. Keeps Multica's runtime artefacts (`output/`, `logs/`, `.gc_meta.json`) in a separate envRoot **outside** the user's directory.
|
||||
|
||||
If a second task for the same directory arrives while the first is running, it parks with status **Waiting for local directory** (等待本地目录释放). The status is visible everywhere the task is — the chat task pill, the agent banner, the execution log, and the activity indicator — and the parked task counts toward the agent's "queued" presence. Cancelling the parked task releases its slot immediately; cancelling the running task lets the next one promote.
|
||||
|
||||
The wait is not a timeout — a parked task stays parked until either the lock releases or the user / agent cancels it.
|
||||
|
||||
### What Multica will and won't touch in your directory
|
||||
|
||||
- **Will write** `CLAUDE.md` / `AGENTS.md` (or the equivalent for your agent's provider) and `.multica/project/resources.json` at the directory root, so the agent has its meta-skill and resource list. Add these to your `.gitignore` if you don't want them committed.
|
||||
- **Will write** whatever code edits the agent decides to make — exactly the same way as if you'd run the agent locally yourself.
|
||||
- **Will never physically delete** the directory or anything inside it. Garbage collection is path-aware: for `local_directory` envRoots it cleans only its own `output/` and `logs/` under `workspacesRoot`, and treats the user's directory as off-limits.
|
||||
|
||||
### v1 limits (will tighten in follow-ups)
|
||||
|
||||
The first release deliberately ships with sharper edges than `github_repo`. Expect this list to shrink over time — what's documented here is what's true today:
|
||||
|
||||
- **No automatic branch switching.** The agent runs in whatever branch you have checked out. Switch branches before dispatching if it matters.
|
||||
- **No dirty-tree protection or auto-commit.** Uncommitted changes are visible to the agent, may be modified in place, and won't be stashed. Treat the directory as a real working tree and commit before risky runs.
|
||||
- **No automatic PR.** When the task ends, the changes sit on whatever branch they were made on — nothing is pushed and no PR is opened. Push and open the PR yourself when you're ready.
|
||||
- **`waiting_local_directory` shows status, not the holder.** The badge tells you the task is parked; it doesn't surface which task or which file path is currently holding the directory.
|
||||
|
||||
These are tracked as the agent-task-lifecycle follow-up to the local-directory work; until that ships, treat `local_directory` as "the agent runs in your folder, the same way you would."
|
||||
|
||||
## Attaching repos at project creation
|
||||
|
||||
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.
|
||||
|
||||
262
apps/docs/content/docs/project-resources.zh.mdx
Normal file
262
apps/docs/content/docs/project-resources.zh.mdx
Normal file
@@ -0,0 +1,262 @@
|
||||
---
|
||||
title: 项目资源
|
||||
description: 给项目挂上有类型的指针(GitHub 仓库、本地目录,未来更多),让智能体执行任务时自动拿到对应上下文。
|
||||
---
|
||||
|
||||
**项目资源(Project Resource)** 是挂在 [项目](/workspaces) 上的有类型的指针 —— 今天可以挂 GitHub 仓库 URL 或者本机的一个目录,未来还会有 Notion 页面、文档链接等等。当 [智能体](/agents) 在这个项目的 issue 上跑任务时,守护进程会自动把项目的资源列表写进智能体的工作目录,并塞进它的 [元 skill](/skills) prompt 里。
|
||||
|
||||
带来的结果:智能体知道应该 checkout 哪个仓库(或者在哪个本地目录里工作),知道项目"主要参考资料"是哪些 —— 没人需要把上下文复制粘贴进 issue 描述里。
|
||||
|
||||
## 心智模型
|
||||
|
||||
项目不再只是一个标签,而是一个小的 **资源容器**:
|
||||
|
||||
- 一个项目可以挂 0..N 个 **资源**。
|
||||
- 每个资源有一个 `resource_type`(比如 `github_repo`、`local_directory`),以及一个 `resource_ref`(按 `resource_type` 定型的 JSON 负载)。
|
||||
- 加新资源类型只需要加一个字符串 + 一个 handler。**不需要 schema 迁移,不需要重写前端。**
|
||||
|
||||
这个形状是有意的 —— 跟 Multica 里 agent provider 的设计完全一致:一个 `type` 判别字段 + 一个有类型的 payload。schema 保持稳定,未来加"Notion 页面"、"Google Doc"、"上传文件"、"外部 URL"都是小型的、增量的改动。
|
||||
|
||||
目前内置两种资源类型:[`github_repo`](#resource-type-github_repo)(每次任务克隆出独立 worktree)和 [`local_directory`](#resource-type-local_directory)(直接在某台守护进程所在机器的目录里运行)。
|
||||
|
||||
## 资源类型:`github_repo`
|
||||
|
||||
默认的资源类型 —— 每次任务都在隔离的 worktree 里 checkout:
|
||||
|
||||
```json
|
||||
{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": {
|
||||
"url": "https://github.com/owner/repo",
|
||||
"default_branch_hint": "main"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`default_branch_hint` 可选 —— 填了之后,守护进程会把它写进元 skill,告诉智能体该基于哪个分支干活。
|
||||
|
||||
## 资源类型:`local_directory`
|
||||
|
||||
对于那些不适合每次任务重新 clone 的仓库 —— 几十 GB 的游戏项目、超大 monorepo、或者本身就不想被多次复制的目录 —— 项目可以改为指向 **某台 [守护进程](/daemon-runtimes) 机器上的一个已有目录**。智能体会 **直接在这个目录里运行**,不 clone、不复制、不创建 worktree。
|
||||
|
||||
```json
|
||||
{
|
||||
"resource_type": "local_directory",
|
||||
"resource_ref": {
|
||||
"local_path": "/Users/me/code/big-game",
|
||||
"daemon_id": "0001234e-…",
|
||||
"label": "主开发目录"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
跟 `github_repo` 相比的取舍是有意的:只有绑定的那台守护进程会使用这个本地目录执行任务,而且 **同一个目录上的任务串行执行**,不再并行。换来的是:你的现有 checkout、当前分支、未提交的脏改动一切照旧 —— Multica 不会重新 clone。
|
||||
|
||||
### 什么时候选 `local_directory`、什么时候继续用 `github_repo`
|
||||
|
||||
| 关注点 | `github_repo`(worktree 模式) | `local_directory` |
|
||||
| --- | --- | --- |
|
||||
| 每次任务的 checkout 成本 | 重新 clone + worktree | 0 —— 智能体原地干活 |
|
||||
| 同一仓库的并发 | 任意条任务并行 | 同一目录每次一条 |
|
||||
| 分支 / 脏改动 | 每条任务从默认分支拉一个干净分支 | 用目录当前的状态 |
|
||||
| 可在哪台机器跑 | 任意守护进程 | 仅绑定的那一台 |
|
||||
| 磁盘占用 | 每条任务一个 worktree | 0 —— 用你已有的目录 |
|
||||
|
||||
下面两种场景下推荐选 `local_directory`:
|
||||
|
||||
1. **重新 clone 的成本过高** —— 几十 GB 的游戏 checkout、带大量 LFS 资源的 monorepo、或者任何场景下 `git clone` 的耗时会盖过实际工作量。你拿并发能力换"零 clone"的运行。
|
||||
2. **改动很细碎,需要频繁在本地 review** —— 你在反复打磨某个组件,几分钟就要切回编辑器看一眼智能体改了什么;与其每次去 `~/multica_workspaces/` 翻一个新 worktree,不如让它就在你已有的 checkout 里干活。
|
||||
|
||||
两种情况下你接受的取舍是同一个:**当前版本没有任何文件级的写入锁**。"同一目录上的任务串行执行"这一道闸是唯一防止两个 issue 的智能体同时改同一份文件的保护。如果你把两个 issue 的智能体都指向同一个 `local_directory`,它们的任务会排队、而不是并行 —— 这是有意的。如果需要在同一份代码上真正的并行,请继续用 `github_repo`。
|
||||
|
||||
### 添加本地目录
|
||||
|
||||
文件夹选择器 **只在桌面端** 提供 —— 浏览器没法读 OS 路径,所以网页端不显示"添加本地目录"按钮。桌面端的流程是:
|
||||
|
||||
1. 打开项目 → **Resources(资源)** 面板。
|
||||
2. 点击 **Add local directory(添加本地目录)**,系统会弹出原生文件夹选择器。
|
||||
3. 选一个文件夹。这个路径会被绑定到 **当前这台桌面端注册的守护进程** —— 资源记录里同时保存路径和该守护进程的 ID。
|
||||
|
||||
在桌面端,当本机的守护进程离线、或者这个项目已经在本机绑定了一个 `local_directory` 时,按钮 **会保留显示但置灰**,并在下方给出一行说明 —— 让你看得到为什么暂时不能用。(网页端则是直接隐藏整个按钮,因为网页端本来就没有文件夹选择器。)要把另一台机器上的目录绑过来,需要在那台机器装上桌面端、并在那边添加资源。
|
||||
|
||||
也可以用 CLI(即便环境是纯网页端也行,只要你自己提供守护进程 ID):
|
||||
|
||||
```bash
|
||||
multica project resource add <project-id> \
|
||||
--type local_directory \
|
||||
--local-path /Users/me/code/big-game \
|
||||
--daemon-id <daemon-uuid> \
|
||||
--ref-label "主开发目录" # 可选
|
||||
|
||||
multica project resource update <project-id> <resource-id> \
|
||||
--local-path /Users/me/code/big-game-new
|
||||
```
|
||||
|
||||
`--daemon-id` 可以通过 `multica daemon list` 拿到。CLI 也接受 `--ref '<json>'` 这种通用形式,直接传 payload。
|
||||
|
||||
### 路径规则
|
||||
|
||||
挂资源时和每次任务启动时,守护进程都会校验一次路径(服务器只负责存 JSON,不做校验)。任何一条不满足的,任务会以一个有类型的错误失败,**不会动你的目录**:
|
||||
|
||||
- 必须是 **绝对路径**。
|
||||
- 必须 **存在**,并且是 **目录**(不能是普通文件、设备节点、或者指向文件的 symlink)。
|
||||
- 守护进程进程必须能 **读 + 写**。
|
||||
- 不能是系统根目录或整个用户配置区 —— `/`、`/Users`、`/home`、`/root`、`/etc`、`/tmp`、`/var`、`/usr`、`/opt`、`/Users/Shared`、你自己的 `$HOME`、任意 Windows 盘根(`C:\`、`D:\`…),以及 `C:\Users` / `C:\ProgramData` / `C:\Program Files` / `C:\Program Files (x86)` / `C:\Windows`。
|
||||
- 指向上述任何路径的 symlink 也会被拒;macOS 的 canonical 别名(比如手动输入 `/private/tmp`)和 `/tmp` 受同样的限制。
|
||||
|
||||
黑名单故意做得激进 —— 选你自己的 home 目录就意味着 Multica 的运行时文件会出现在你账号的根下,这不会是任何人想要的结果。请选一个子目录(一般就是你具体的项目 checkout)。
|
||||
|
||||
### 每台守护进程每个项目最多一条
|
||||
|
||||
同一项目上 **每台守护进程最多只能绑一个 `local_directory`**。在同一台机器上想再加一条会被 API 用 `409` 拒绝;桌面端的按钮在达到上限时会自动隐藏,并在 tooltip 里说明原因。
|
||||
|
||||
不同的守护进程互不影响 —— 一个共享的项目可以为每个队友的机器各绑一个 `local_directory`,把同一个项目挂到不同主机上的不同目录。守护进程领任务时会挑跟自己 ID 匹配的那一行,其它的忽略。
|
||||
|
||||
### 混用资源类型,以及多个 `local_directory` 资源
|
||||
|
||||
实际会碰到两种跨资源的组合:
|
||||
|
||||
- **同一个项目同时挂 `github_repo` 和 `local_directory`。** 在拥有匹配 `local_directory` 绑定的那台守护进程上,本地目录 **优先**:智能体跑在你的目录里,这次任务不会为项目的 `github_repo` 创建或使用 worktree。(每工作区的仓库缓存可能仍会照常 sync,这层是和具体任务无关的后台行为。)`github_repo` 的 URL 仍然会出现在 `.multica/project/resources.json` 和智能体的 `## Repositories` 段里供参考,但智能体真正在编辑的工作树是你的本地目录,不是 worktree。在 **没有** 匹配 `local_directory` 行的其他守护进程上(不同机器、或者那位队友还没挂本地目录),任务按原本的 `github_repo` worktree 流程走。本质上 `local_directory` 是某台守护进程对 worktree 路径的一个覆盖。
|
||||
- **同一个项目挂两个 `local_directory`。** 每个 `local_directory` 只能绑一台守护进程,所以这只会发生在两台不同机器之间(同一台机器上想加第二条会被 API 当场拒掉,见上一节)。任务的去向由智能体 runtime 绑定决定,不是由"哪台守护进程有本地目录"决定 —— 任务会落到承载这条智能体 runtime 的那台守护进程上,由它挑出匹配自己 ID 的那一行、忽略其他行。这里没有负载均衡:如果你想让某台机器跑某条任务,请把任务派给绑在那台机器 runtime 上的智能体。
|
||||
|
||||
如果某台守护进程没有匹配这个项目的 `local_directory` 行,**不会**因为别处有人绑了就被拦下 —— 它的任务会照常走项目里的其他资源(一般就是 `github_repo` 那条 fallback 路径)。`local_directory` 只对它绑定的那台守护进程生效。
|
||||
|
||||
### 任务在本地目录里如何运行
|
||||
|
||||
当一条任务被分发到某个 issue、并且该项目在领任务的守护进程上绑了 `local_directory` 时,守护进程会:
|
||||
|
||||
1. 再次校验路径(按上面那套规则)。
|
||||
2. 以 symlink 解析后的真实路径为 key 取一把"目录锁" —— 即便两条路径走不同路由(一条经过 symlink,一条直接)指向同一个文件夹,也会被串行化。
|
||||
3. 把智能体的 `CLAUDE.md` / `AGENTS.md`(以及 `.multica/project/resources.json`)**写进你的目录**。智能体就在那里工作,跟你自己打开这个文件夹后启动它是一样的体验。
|
||||
4. Multica 自己的运行时产物(`output/`、`logs/`、`.gc_meta.json`)放在 **你目录之外** 的一个独立 envRoot 里。
|
||||
|
||||
如果第一条任务还在跑、第二条针对同一目录的任务又来了,第二条会停在 **Waiting for local directory(等待本地目录释放)** 状态。这个状态在所有相关 UI 都看得见 —— 聊天里的任务状态条、智能体横幅、执行记录、活动指示器;等待中的任务会被计入该智能体的"排队"占用。取消等待中的任务会立即释放它的槽位;取消正在跑的那条会让下一条立即顶上。
|
||||
|
||||
等待没有超时 —— 一条等待中的任务会一直等到锁释放、或者被用户/智能体取消为止。
|
||||
|
||||
### Multica 会写、不会动什么
|
||||
|
||||
- **会写入** `CLAUDE.md` / `AGENTS.md`(或对应 provider 的等价文件)以及 `.multica/project/resources.json` 到目录根 —— 这样智能体才有自己的元 skill 和资源清单。如果不想把这些提交到 git,请加进 `.gitignore`。
|
||||
- **会写入** 智能体决定做出的所有代码改动 —— 跟你自己在本地跑这个智能体没有区别。
|
||||
- **永远不会物理删除** 你的目录或里面任何内容。垃圾回收对路径是有判断的:对 `local_directory` 的 envRoot,它只会清自己挂在 `workspacesRoot` 下的 `output/` 与 `logs/`,**完全不碰** 你的目录。
|
||||
|
||||
### v1 阶段的限制(后续会逐步收敛)
|
||||
|
||||
第一版有意比 `github_repo` 留了更多锋利的边。下面这份清单会随着后续工作逐步缩短;这里列出的是 **今天**的真实行为:
|
||||
|
||||
- **不会自动切分支。** 智能体跑在你当前 checkout 的分支上。如果分支选择重要,请在分发任务前自己切换好。
|
||||
- **不会保护脏工作区,也不会自动 commit。** 未提交的改动智能体看得到、可能就地修改,不会被 stash。请把这个目录当作真实的工作区来对待,重要的运行前自己先 commit。
|
||||
- **不会自动开 PR。** 任务结束后,改动就停在它实际做出来的分支上 —— Multica 不会 push、不会开 PR。需要 push、需要 PR 的话请自己来。
|
||||
- **`waiting_local_directory` 只显示状态,不显示是谁占着。** 这个徽标只告诉你"任务在等",不会告诉你具体是哪条任务、哪个路径正在持有目录。
|
||||
|
||||
这些都列在 local-directory 工作的"智能体任务生命周期"后续项里;在那之前,请把 `local_directory` 当作"智能体在你的目录里跑,跟你自己跑没区别"来用。
|
||||
|
||||
## 在创建项目时挂仓库
|
||||
|
||||
在 **网页端** 或 **桌面端**,打开 *新建项目* 时,Status / Priority / Lead 旁边多了一个 **Repos** 标签。选已绑到工作区的仓库(或者粘贴一个临时 URL),项目创建的瞬间它们就以 `github_repo` 资源的形式挂上去。
|
||||
|
||||
通过 **CLI**:
|
||||
|
||||
```bash
|
||||
# 创建 + 挂资源一步到位。资源是在同一个事务里挂上的 ——
|
||||
# 任何一个资源不合法都会让整个 create 回滚,绝不会出现
|
||||
# "项目建好了但资源只挂了一半"的状态。
|
||||
multica project create \
|
||||
--title "Agent UX 2026" \
|
||||
--repo https://github.com/multica-ai/multica
|
||||
|
||||
# 后续管理资源
|
||||
multica project resource list <project-id>
|
||||
multica project resource add <project-id> --type github_repo --url <url>
|
||||
multica project resource remove <project-id> <resource-id>
|
||||
|
||||
# 任何服务器认识的 resource_type 都可以用这个通用入口 ——
|
||||
# 加新类型时不需要改 CLI:
|
||||
multica project resource add <project-id> \
|
||||
--type notion_page \
|
||||
--ref '{"page_id":"…","title":"…"}'
|
||||
```
|
||||
|
||||
`--repo` 可以重复指定;每个值会被当作一条单独的 `github_repo` 资源挂上去。
|
||||
|
||||
## 运行时智能体看到什么
|
||||
|
||||
当守护进程为一个项目内的 issue 启动智能体时,会发生两件事:
|
||||
|
||||
### 1. `.multica/project/resources.json`
|
||||
|
||||
API 响应的结构化透传,写进智能体的工作目录里:
|
||||
|
||||
```json
|
||||
{
|
||||
"project_id": "…",
|
||||
"project_title": "Agent UX 2026",
|
||||
"resources": [
|
||||
{
|
||||
"id": "…",
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": {
|
||||
"url": "https://github.com/multica-ai/multica",
|
||||
"default_branch_hint": "main"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Skill、辅助脚本、或者智能体自己想拿到本次运行**精确的**资源列表时,解析这个文件即可。
|
||||
|
||||
### 2. 元 skill prompt 里的 "Project Context" 段
|
||||
|
||||
智能体的 `CLAUDE.md` / `AGENTS.md`(按 provider 不同)里多出一段人类可读的摘要:
|
||||
|
||||
```
|
||||
## Project Context
|
||||
|
||||
This issue belongs to **Agent UX 2026**.
|
||||
|
||||
Project resources (also written to `.multica/project/resources.json`):
|
||||
|
||||
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
|
||||
|
||||
Resources are pointers — open them only when relevant to the task. For
|
||||
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
|
||||
```
|
||||
|
||||
这段文字故意做得很精简:完整 payload 已经写到磁盘上了,prompt 只是给智能体一个定位 —— 让它知道项目存在、知道挂了什么。
|
||||
|
||||
### 失败时
|
||||
|
||||
资源拉取是 **best-effort**。如果 API 调失败了,prompt 里的项目段会省掉、文件也不写,但任务照常启动 —— 智能体永远不会因为缺少项目上下文而卡住。
|
||||
|
||||
## 加一种新资源类型
|
||||
|
||||
抽象的全部目的就是让加新类型变便宜。完整流程:
|
||||
|
||||
1. **服务端校验器**(`server/internal/handler/project_resource.go`)—— 在 `validateAndNormalizeResourceRef` 里加一个 case,解析并标准化新的 payload。
|
||||
2. **守护进程元 skill 格式化器**(`server/internal/daemon/execenv/runtime_config.go`)—— 在 `formatProjectResource` 里加一个 case,让智能体 prompt 把新类型渲染成一条可读的列表项。
|
||||
3. **TypeScript 类型**(`packages/core/types/project.ts`)—— 给 `ProjectResourceType` 加值,并加上对应的 payload 接口。
|
||||
4. **UI 渲染**(`packages/views/projects/components/project-resources-section.tsx`)—— 在 `ResourceRow` 里加一个 case 渲染新类型。
|
||||
|
||||
**不需要 schema 迁移、不需要新的 sqlc query、不需要新的 endpoint、也不需要改 CLI** —— CLI 的通用 `--ref '<json>'` 选项接受校验器认识的任何 payload,第 0 天就能用。(之后如果想加按类型的 CLI 快捷参数,可以加,但不是必须。)
|
||||
|
||||
同一张 `project_resource` 表、同一套 CRUD 调用,处理所有类型。
|
||||
|
||||
## 工作区仓库 vs. 项目仓库
|
||||
|
||||
智能体看到的仓库列表(`CLAUDE.md` / `AGENTS.md` 里的 `## Repositories` 段)由守护进程的领任务逻辑按以下优先级决定:
|
||||
|
||||
- **项目挂了至少一条 `github_repo` 资源** → 仅显示项目挂的仓库。工作区绑定的仓库会被隐藏,避免智能体猜哪一个属于这个 issue。
|
||||
- **项目没挂 `github_repo` 资源(或 issue 根本不在项目里)** → 回落到工作区的仓库列表,跟以前一样。
|
||||
|
||||
这样可以把智能体的工作集压紧:项目把仓库说清楚了,那就是权威答案。而 `.multica/project/resources.json` 里始终带着完整列表,需要查看全部资源的 skill 仍然能拿到。
|
||||
|
||||
守护进程在 checkout 这一侧也是配套的:当任务带着项目级别的 `github_repo` URL 进来时,这些 URL 会被合并进每工作区的白名单,并在智能体启动前同步进本地仓库缓存。所以即便某个项目仓库 URL 没被绑到工作区,`multica repo checkout` 也能正常处理它,不会以"未配置"为由拒绝。白名单的划分只是内部实现:工作区绑定的 URL 和任务级别的 URL 分开记录,工作区仓库列表的刷新不会顺手吊销某条项目 URL。
|
||||
|
||||
## 当前**不**做的事
|
||||
|
||||
- **跨项目共享**。每条资源现在只能挂在一个项目上。
|
||||
- **按 skill 划定资源可见性**。所有资源对智能体本次运行里的每个 skill 都可见;按类型过滤是后续工作。
|
||||
- **缓存 / 同步**。`github_repo` 目前只是元数据 —— checkout 仍然按需走 `multica repo checkout`。Notion / Google Docs 之类的文档文本缓存会随对应类型一起到位。
|
||||
|
||||
这些是有意的留白 —— 第一版的目标是用最小的活动部件去验证这个抽象。
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: AI coding tools matrix
|
||||
description: Multica supports 11 AI coding tools; they implement the same interface, but the capability details diverge significantly.
|
||||
description: Multica supports 12 AI coding tools; they implement the same interface, but the capability details diverge significantly.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica ships with built-in support for **11 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
|
||||
Multica ships with built-in support for **12 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
|
||||
|
||||
For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create).
|
||||
|
||||
@@ -13,6 +13,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
|
||||
| Tool | Vendor | Session resumption | MCP | Skill injection path | Model selection |
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Managed inside the Antigravity CLI itself |
|
||||
| **Claude Code** | Anthropic | ✅ | **✅ (the only one that actually uses it)** | `.claude/skills/` | Static + flag |
|
||||
| **Codex** | OpenAI | ⚠️ Code exists but unreachable | ❌ | `$CODEX_HOME/skills/` | Static |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
|
||||
@@ -27,6 +28,10 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
|
||||
## What each tool is for
|
||||
|
||||
### Antigravity
|
||||
|
||||
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. There is no `--model` flag — model selection lives inside the Antigravity CLI settings, so Multica disables the per-agent model picker for this provider. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
|
||||
|
||||
### Claude Code
|
||||
|
||||
From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 11 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
|
||||
@@ -77,7 +82,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
|
||||
|
||||
| Status | Tools | Meaning |
|
||||
|---|---|---|
|
||||
| ✅ Really works | Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ✅ Really works | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** |
|
||||
| ❌ None | Gemini | The CLI has no resume mechanism |
|
||||
|
||||
@@ -85,7 +90,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
|
||||
|
||||
## MCP configuration: only Claude Code actually reads it
|
||||
|
||||
**Of the 11 tools, only Claude Code actually consumes `mcp_config`**. The other 10 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
|
||||
**Of the 12 tools, only Claude Code actually consumes `mcp_config`**. The other 11 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
|
||||
|
||||
<Callout type="warning">
|
||||
If you set `mcp_config` in an agent configuration but pick a tool other than Claude Code, your MCP servers have **no effect** on that agent. MCP integration currently covers Claude Code only.
|
||||
@@ -105,6 +110,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ Native |
|
||||
| OpenCode | `.opencode/skills/` | ✅ Native |
|
||||
| Pi | `.pi/skills/` | ✅ Native |
|
||||
| Antigravity | `.agents/skills/` | ✅ Native (inherits Gemini CLI's workspace layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)) |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
@@ -118,3 +124,4 @@ For creating and using skills, see [Skills](/skills).
|
||||
- [Creating and configuring agents](/agents-create) — pick a tool for your agent
|
||||
- [Tasks](/tasks) — task lifecycle and session-resumption mechanics
|
||||
- [Daemon and runtimes](/daemon-runtimes) — where the tools run and how they connect to Multica
|
||||
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 12 supported tools
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: AI 编程工具对照
|
||||
description: Multica 支持 11 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
|
||||
description: Multica 支持 12 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
|
||||
Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
|
||||
|
||||
创建智能体时挑选工具的指引见 [创建和配置智能体](/agents-create)。
|
||||
|
||||
@@ -13,6 +13,7 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
|
||||
|
||||
| 工具 | 厂商 | 会话恢复 | MCP | Skill 注入路径 | 模型选择 |
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅(`--conversation <id>`)| ❌ | `.agents/skills/` | 由 Antigravity CLI 自己管理 |
|
||||
| **Claude Code** | Anthropic | ✅ | **✅(唯一真用)** | `.claude/skills/` | 静态 + flag |
|
||||
| **Codex** | OpenAI | ⚠️ 代码存在但不可达 | ❌ | `$CODEX_HOME/skills/` | 静态 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
|
||||
@@ -27,9 +28,13 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
|
||||
|
||||
## 每款工具的定位
|
||||
|
||||
### Antigravity
|
||||
|
||||
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。CLI 没有 `--model` flag——模型选择保存在 Antigravity 自己的设置里,因此 Multica 禁用了这款工具的模型选择控件。Skill 文件写入 `.agents/skills/`(CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
|
||||
|
||||
### Claude Code
|
||||
|
||||
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **11 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
|
||||
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **12 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
|
||||
|
||||
### Codex
|
||||
|
||||
@@ -77,7 +82,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
|
||||
| 状态 | 工具 | 含义 |
|
||||
|---|---|---|
|
||||
| ✅ 真用 | Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ✅ 真用 | Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到(Codex 静默回落、Cursor session id 不回传)—— **当作不支持** |
|
||||
| ❌ 无 | Gemini | CLI 无 resume 机制 |
|
||||
|
||||
@@ -85,7 +90,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
|
||||
## MCP 配置:只有 Claude Code 真的读
|
||||
|
||||
**11 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 10 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
|
||||
**12 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 11 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
|
||||
|
||||
<Callout type="warning">
|
||||
如果你在智能体配置里设置了 `mcp_config`,但选了 Claude Code 之外的工具,你的 MCP server 对这个智能体**没有效果**。目前的 MCP 集成只覆盖 Claude Code。
|
||||
@@ -105,6 +110,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.opencode/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/skills/` | ✅ 原生 |
|
||||
| Antigravity | `.agents/skills/` | ✅ 原生(沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))|
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
|
||||
@@ -82,17 +82,31 @@ Two delivery backends are supported — pick whichever fits your network:
|
||||
|
||||
**Option B — SMTP relay (internal networks / on-premise):**
|
||||
|
||||
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.
|
||||
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set, so verification and invite mail stays on the internal relay. Port 465 (SMTPS / implicit TLS) is not currently supported — use 25 or 587.
|
||||
|
||||
For **anonymous Exchange internal relay (port 25)** — the host is trusted by IP and submits without credentials:
|
||||
|
||||
```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_HOST=exchange.internal.example.com
|
||||
SMTP_PORT=25
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_INSECURE=false
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`.
|
||||
For **authenticated submission (port 587, STARTTLS)** — the relay requires a service account; STARTTLS is upgraded automatically when advertised:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=multica
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # set true only for private CA / self-signed
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`. On restart, the backend prints which provider it picked (`EmailService: SMTP relay …` / `Resend API` / `DEV mode`) — credentials are never logged, so this line is safe to share when asking for help.
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -81,17 +81,31 @@ make selfhost
|
||||
|
||||
**Option B — SMTP relay(内网/自部署):**
|
||||
|
||||
适合内网无法访问 `api.resend.com`,或已经有内部邮件中继(Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend。
|
||||
适合内网无法访问 `api.resend.com`,或已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend,验证码和邀请邮件不会走外部 provider。**暂不支持** 465(SMTPS / 隐式 TLS),请使用 25 或 587。
|
||||
|
||||
**匿名 Exchange 内部 relay(端口 25)** —— 主机按 IP 被信任,不需要凭据:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 587
|
||||
SMTP_USERNAME=multica # 留空则使用未认证 relay
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_HOST=exchange.internal.example.com
|
||||
SMTP_PORT=25
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_INSECURE=false
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。
|
||||
**认证提交(端口 587,STARTTLS)** —— relay 需要 service account;服务端 advertise STARTTLS 时自动升级:
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=multica
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。重启时 backend 会打印当前选择的 provider(`EmailService: SMTP relay …` / `Resend API` / `DEV mode`),密码不会被记录,所以这行截图给同事是安全的。
|
||||
|
||||
更多 auth 配置(OAuth、注册白名单)以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Both individual files and whole skill packs have size caps (single-file cap arou
|
||||
|
||||
Once imported, a skill has to be **attached to a specific agent** to take effect. One agent can have multiple skills attached, and one skill can be attached to multiple agents.
|
||||
|
||||
After attaching, the agent picks up its skills the next time it starts a task — each AI coding tool has its own skill discovery path (Claude Code uses `.claude/skills/`, Cursor uses `.cursor/skills/`, etc.), and Multica drops files in the right place automatically. **However, three tools (Gemini, Hermes, OpenClaw) currently use the generic fallback path `.agent_context/skills/` — whether these tools actually read skills from that path depends on the tool itself.** Full path mapping and the native-discovery vs. fallback distinction is in [AI coding tools comparison → Where skill files go](/providers#where-skill-files-go).
|
||||
After attaching, the agent picks up its skills the next time it starts a task — each AI coding tool has its own skill discovery path (Claude Code uses `.claude/skills/`, Cursor uses `.cursor/skills/`, Antigravity uses `.agents/skills/`, etc.), and Multica drops files in the right place automatically. **However, three tools (Gemini, Hermes, OpenClaw) currently use the generic fallback path `.agent_context/skills/` — whether these tools actually read skills from that path depends on the tool itself.** Full path mapping and the native-discovery vs. fallback distinction is in [AI coding tools comparison → Where skill files go](/providers#where-skill-files-go).
|
||||
|
||||
After you edit a skill's contents, **only newly created tasks pick up the new version** — tasks already running continue with the old skill.
|
||||
|
||||
@@ -64,4 +64,4 @@ By now you know what an agent is, how to create one, and how to attach skills. T
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — where agents actually run, and how to tell online from offline
|
||||
- [Executing tasks](/tasks) — the full lifecycle of one "agent work session"
|
||||
- [AI coding tools comparison](/providers) — full comparison of all 11 tools (including each one's skill injection path)
|
||||
- [AI coding tools comparison](/providers) — full comparison of all 12 tools (including each one's skill injection path)
|
||||
|
||||
@@ -31,7 +31,7 @@ Multica 支持两种 Skill 来源:
|
||||
|
||||
Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能体能挂多个 Skill,一个 Skill 也能挂到多个智能体。
|
||||
|
||||
挂上之后,智能体下次开工时会自动拿到挂着的 Skill——不同 AI 编程工具有各自的 Skill 发现路径(Claude Code 是 `.claude/skills/`、Cursor 是 `.cursor/skills/` 等),Multica 会自动放到对的位置。**但有 3 款工具(Gemini / Hermes / OpenClaw)当前走的是通用 fallback 路径 `.agent_context/skills/`——这些工具能否真的从这里读到 skill,取决于工具本身是否支持**。完整路径对照和原生发现 vs fallback 的区分见 [AI 编程工具对照 → skill 文件该放哪儿](/providers#skill-文件该放哪儿)。
|
||||
挂上之后,智能体下次开工时会自动拿到挂着的 Skill——不同 AI 编程工具有各自的 Skill 发现路径(Claude Code 是 `.claude/skills/`、Cursor 是 `.cursor/skills/`、Antigravity 是 `.agents/skills/` 等),Multica 会自动放到对的位置。**但有 3 款工具(Gemini / Hermes / OpenClaw)当前走的是通用 fallback 路径 `.agent_context/skills/`——这些工具能否真的从这里读到 skill,取决于工具本身是否支持**。完整路径对照和原生发现 vs fallback 的区分见 [AI 编程工具对照 → skill 文件该放哪儿](/providers#skill-文件该放哪儿)。
|
||||
|
||||
修改 Skill 的内容后,**只有之后新创建的任务会拿到新版本**——正在跑的任务继续用旧版 Skill。
|
||||
|
||||
@@ -64,4 +64,4 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
|
||||
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 智能体到底跑在哪、怎么判断在线 / 离线
|
||||
- [执行任务](/tasks) —— 一次"智能体工作"的完整生命周期
|
||||
- [AI 编程工具对照](/providers) —— 11 款工具的完整对比(含每款的 Skill 注入路径)
|
||||
- [AI 编程工具对照](/providers) —— 12 款工具的完整对比(含每款的 Skill 注入路径)
|
||||
|
||||
@@ -105,7 +105,7 @@ Multica pins the session ID **twice** during a task: once at the start (when the
|
||||
|
||||
But **which AI coding tools actually support this** varies a lot:
|
||||
|
||||
- ✅ **Real support** — Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ✅ **Real support** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **Code exists but unusable** — Codex, Cursor
|
||||
- ❌ **No support** — Gemini
|
||||
|
||||
@@ -113,5 +113,5 @@ See [Providers Matrix → Session resumption](/providers#session-resumption-who-
|
||||
|
||||
## Next
|
||||
|
||||
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools (including the exact session-resumption status)
|
||||
- [Providers Matrix](/providers) — capability differences across the 12 AI coding tools (including the exact session-resumption status)
|
||||
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task
|
||||
|
||||
@@ -105,7 +105,7 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
|
||||
|
||||
但**哪些 AI 编程工具真的支持**差别很大:
|
||||
|
||||
- ✅ **真支持**——Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
|
||||
- ✅ **真支持**——Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
|
||||
- ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor
|
||||
- ❌ **不支持**——Gemini
|
||||
|
||||
@@ -113,5 +113,5 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
|
||||
- [Providers Matrix](/providers) —— 12 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
|
||||
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Autopilots](/autopilots) —— 触发执行任务的四种方式
|
||||
|
||||
@@ -89,6 +89,20 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
|
||||
|
||||
**Symptom**: after submitting an email during sign-in or invite acceptance, neither the inbox nor the spam folder has the verification code.
|
||||
|
||||
**First, confirm which provider the server thinks is active.** At startup the backend prints one of:
|
||||
|
||||
- `EmailService: SMTP relay <host>:<port> from=<addr>` — using SMTP (`SMTP_HOST` non-empty wins over Resend)
|
||||
- `EmailService: Resend API from=<addr>` — using Resend
|
||||
- `EmailService: DEV mode — codes printed to stdout …` — no provider configured
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"
|
||||
```
|
||||
|
||||
If the line you expected isn't there, the environment didn't reach the process — check `.env` and `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`. Credentials are never logged on this startup line.
|
||||
|
||||
### When Resend is the active provider
|
||||
|
||||
**Likely causes**:
|
||||
|
||||
1. **`RESEND_API_KEY` not set** — the server silently falls back and **writes the code to its own stdout** without error. Easy to trip over in production
|
||||
@@ -108,6 +122,34 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
|
||||
- Domain not verified → run the DNS verification flow in the Resend console (add SPF / DKIM records)
|
||||
- In an emergency (internal testing) → copy the code printed under `[DEV]` from the server logs
|
||||
|
||||
### When SMTP is the active provider
|
||||
|
||||
The SMTP path wraps every failure with the stage it failed at, so the server logs already tell you where the relay rejected the session. Grep for `"failed to send verification email"` / `"failed to send invitation email"` and check the wrapped error:
|
||||
|
||||
| Logged error | What it means | How to fix |
|
||||
|---|---|---|
|
||||
| `smtp dial <host>:<port>: dial tcp …: connect: connection refused` / `i/o timeout` | The backend container can't reach the relay — wrong host, wrong port, firewall, or the relay isn't listening | Verify `SMTP_HOST` / `SMTP_PORT` resolve from inside the container (`docker compose -f docker-compose.selfhost.yml exec backend nslookup <host>` and `nc -vz <host> <port>`); open the firewall from the host running Multica to the relay |
|
||||
| `smtp starttls: x509: certificate signed by unknown authority` (or `certificate is not valid for any names`) | The relay uses a private CA / self-signed cert and the container's trust store rejects it | Either install the CA into the container, or set `SMTP_TLS_INSECURE=true` only after confirming the relay is reachable on a trusted segment |
|
||||
| `smtp auth: 535 5.7.8 Authentication credentials invalid` (or `534`/`530`) | `SMTP_USERNAME` / `SMTP_PASSWORD` are wrong, or the relay requires a different auth mechanism than `PLAIN` | Re-confirm the service-account credentials with your mail admin; for Exchange anonymous internal relay leave both empty (`SMTP_USERNAME=`, `SMTP_PASSWORD=`) |
|
||||
| `smtp MAIL FROM: 550 5.7.1 Client does not have permissions to send as this sender` | The relay won't accept `RESEND_FROM_EMAIL` as the envelope sender — typical Exchange "anonymous users not allowed" or DMARC alignment issue | Set `RESEND_FROM_EMAIL` to a domain the relay accepts; on Exchange, grant the source IP `ms-Exch-SMTP-Accept-Any-Sender` on the receive connector |
|
||||
| `smtp RCPT TO <addr>: 550 5.7.1 Unable to relay` | The relay's receive connector doesn't allow your subnet to relay to external recipients (most common for anonymous internal relays talking to outside domains) | Either restrict invites to internal recipients, or add the Multica host's subnet to the Exchange "Anonymous Users → Relay" permission list |
|
||||
| `smtp DATA` / `smtp write body` / `smtp end data` | Session was accepted but the relay dropped the body — usually message-size limits, content filtering, or a connection reset mid-stream | Check the relay's logs for the same `Message-ID` (logged as `<unixnano>@<host>`); raise the message size limit if needed |
|
||||
|
||||
`MAIL FROM`, `RCPT TO`, and `DATA` errors are always logged with the relay's response code so you can match them against Exchange / Postfix logs on the other side. Verification codes and invite tokens are **never** included in the wrapped error.
|
||||
|
||||
**How to diagnose**:
|
||||
|
||||
- Grep `"EmailService: SMTP relay"` once at startup, then `"failed to send"` for runtime failures
|
||||
- From inside the backend container, sanity-check connectivity: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'`
|
||||
- Confirm the env reached the process: `docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_` (password will be in the output — only run on a trusted shell)
|
||||
|
||||
**How to fix**:
|
||||
|
||||
- Wrong host / port → adjust `SMTP_HOST` / `SMTP_PORT` and restart the backend; for the supported relay modes see [Auth setup → Option B: SMTP relay](/auth-setup)
|
||||
- Cert mismatch → install the relay's CA into the container, or temporarily `SMTP_TLS_INSECURE=true` on a trusted segment
|
||||
- Auth failure → re-check credentials; for anonymous internal relay leave `SMTP_USERNAME` and `SMTP_PASSWORD` empty
|
||||
- `Unable to relay` → either restrict to internal recipients or grant the Multica host's IP relay permission on the Exchange receive connector
|
||||
|
||||
## Fixed local test code doesn't work
|
||||
|
||||
**Symptom**: on a self-hosted instance, you try to sign in with a fixed local test code such as `888888` and it's rejected with `invalid or expired code`.
|
||||
|
||||
@@ -89,6 +89,20 @@ multica issue show <issue-id> # 看 task 历史
|
||||
|
||||
**症状**:登录或邀请时提交邮箱后,收件箱(和垃圾邮件)里都没有验证码邮件。
|
||||
|
||||
**先确认 server 自己认为在用哪个 provider。** 启动时 backend 会打印这三种之一:
|
||||
|
||||
- `EmailService: SMTP relay <host>:<port> from=<addr>` —— 走 SMTP(`SMTP_HOST` 非空时优先级高于 Resend)
|
||||
- `EmailService: Resend API from=<addr>` —— 走 Resend
|
||||
- `EmailService: DEV mode — codes printed to stdout …` —— 没配任何 provider
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"
|
||||
```
|
||||
|
||||
如果应该出现的那行没出现,说明环境变量没进到进程 —— 检查 `.env` 和 `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`。这行启动日志里**不会**打印任何密码。
|
||||
|
||||
### Resend 是当前 provider
|
||||
|
||||
**可能原因**:
|
||||
|
||||
1. **`RESEND_API_KEY` 没配** —— server 会静默回落,**把验证码打到自己的 stdout 里**,不报错。生产部署很容易踩
|
||||
@@ -108,6 +122,34 @@ multica issue show <issue-id> # 看 task 历史
|
||||
- 域名没验证 → Resend console 里走 DNS 验证流程(加 SPF / DKIM 记录)
|
||||
- 紧急情况下(如内部测试)→ 从 server 日志里抄 `[DEV]` 打印出的验证码
|
||||
|
||||
### SMTP 是当前 provider
|
||||
|
||||
SMTP 路径把每个失败都按阶段包装好,所以 server 日志已经告诉你 relay 在哪一步拒绝了会话。搜 `"failed to send verification email"` / `"failed to send invitation email"`,看里面包的具体错误:
|
||||
|
||||
| 错误日志 | 含义 | 怎么修 |
|
||||
|---|---|---|
|
||||
| `smtp dial <host>:<port>: dial tcp …: connect: connection refused` / `i/o timeout` | backend 容器连不上 relay —— host / port 错、防火墙挡了、或者 relay 没开 | 在容器里确认能解析和连通:`docker compose -f docker-compose.selfhost.yml exec backend nslookup <host>` 以及 `nc -vz <host> <port>`;放行从 Multica 主机到 relay 的网络 |
|
||||
| `smtp starttls: x509: certificate signed by unknown authority`(或 `certificate is not valid for any names`) | relay 用了私有 CA / 自签证书,容器的信任库不接受 | 要么把 CA 装进容器,要么在确认 relay 走的是可信网段后设 `SMTP_TLS_INSECURE=true` |
|
||||
| `smtp auth: 535 5.7.8 Authentication credentials invalid`(或 `534`/`530`) | `SMTP_USERNAME` / `SMTP_PASSWORD` 不对,或 relay 不接受 `PLAIN` 认证 | 找邮件管理员复核 service account 凭据;Exchange 匿名内部 relay 应当把两者都留空 |
|
||||
| `smtp MAIL FROM: 550 5.7.1 Client does not have permissions to send as this sender` | relay 不接受 `RESEND_FROM_EMAIL` 作为信封发件人 —— Exchange 常见 "anonymous users not allowed" 或 DMARC 对齐问题 | 把 `RESEND_FROM_EMAIL` 改成 relay 接受的域名;Exchange 上给来源 IP 授 `ms-Exch-SMTP-Accept-Any-Sender` |
|
||||
| `smtp RCPT TO <addr>: 550 5.7.1 Unable to relay` | relay 的 receive connector 不允许这个子网把邮件中继到外部收件人(匿名内部 relay 发给外部域时常见) | 邀请仅限内部域,或者把 Multica 主机的子网加进 Exchange "Anonymous Users → Relay" 权限列表 |
|
||||
| `smtp DATA` / `smtp write body` / `smtp end data` | 会话被接受但 body 被丢 —— 通常是消息大小限制、内容过滤、或中途断连 | 在 relay 端按同一个 `Message-ID`(日志里是 `<unixnano>@<host>`)找上下文;必要时调大消息大小限制 |
|
||||
|
||||
`MAIL FROM` / `RCPT TO` / `DATA` 的错误日志里都带着 relay 返回的状态码,可以和 Exchange / Postfix 那边的日志对齐。验证码和邀请 token **不会**出现在这些包装的错误里。
|
||||
|
||||
**怎么查**:
|
||||
|
||||
- 启动时搜 `"EmailService: SMTP relay"` 一次,运行时搜 `"failed to send"` 看具体阶段
|
||||
- 在 backend 容器里测连通:`docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'`
|
||||
- 确认环境变量真进到了进程:`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_`(这条会带出密码,仅在可信终端运行)
|
||||
|
||||
**怎么修**:
|
||||
|
||||
- host / port 不对 → 改 `SMTP_HOST` / `SMTP_PORT` 后重启 backend;支持的 relay 模式见 [登录与注册配置 → Option B:SMTP relay](/auth-setup)
|
||||
- 证书校验失败 → 把 relay 的 CA 装进容器,或在可信网段上临时 `SMTP_TLS_INSECURE=true`
|
||||
- 认证失败 → 复核凭据;匿名内部 relay 应把 `SMTP_USERNAME` 和 `SMTP_PASSWORD` 都留空
|
||||
- `Unable to relay` → 邀请仅限内部域,或在 Exchange receive connector 上给 Multica 主机授中继权限
|
||||
|
||||
## 固定本地测试验证码登不进去
|
||||
|
||||
**症状**:自部署实例,想用 `888888` 这类固定本地测试验证码登录,但被拒 `invalid or expired code`。
|
||||
|
||||
6
apps/mobile/.gitignore
vendored
6
apps/mobile/.gitignore
vendored
@@ -21,6 +21,12 @@ android/
|
||||
!data/
|
||||
!data/**
|
||||
|
||||
# Re-ignore macOS metadata that the broader `!data/**` rule above would
|
||||
# otherwise pull back in. The `.DS_Store` line at the top of this file is
|
||||
# overridden by the negation, so we restate it here in last-match-wins
|
||||
# position.
|
||||
data/**/.DS_Store
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const PAST_STATUS_ORDER: Record<AgentTask["status"], number> = {
|
||||
completed: 2,
|
||||
queued: 99,
|
||||
dispatched: 99,
|
||||
waiting_local_directory: 99,
|
||||
running: 99,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* — both surfaces produce canonical `[@name](mention://type/id)` markdown
|
||||
* recognised by util.ParseMentions on the server.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
@@ -93,18 +93,16 @@ export default function NewIssueModal() {
|
||||
createIssue,
|
||||
]);
|
||||
|
||||
const headerRight = useMemo(() => {
|
||||
function HeaderRight() {
|
||||
return (
|
||||
<SubmitIssueButton
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
onPress={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return HeaderRight;
|
||||
}, [canSubmit, isSubmitting, onSubmit]);
|
||||
const headerRight = useCallback(
|
||||
() => (
|
||||
<SubmitIssueButton
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
onPress={onSubmit}
|
||||
/>
|
||||
),
|
||||
[canSubmit, isSubmitting, onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -133,6 +133,7 @@ function fallbackSummary(task: AgentTask): string {
|
||||
const STATUS_LABEL: Record<AgentTask["status"], string> = {
|
||||
queued: "Queued",
|
||||
dispatched: "Starting",
|
||||
waiting_local_directory: "Waiting for directory",
|
||||
running: "Running",
|
||||
completed: "Done",
|
||||
failed: "Failed",
|
||||
@@ -142,6 +143,7 @@ const STATUS_LABEL: Record<AgentTask["status"], string> = {
|
||||
const STATUS_CLASS: Record<AgentTask["status"], string> = {
|
||||
queued: "text-muted-foreground",
|
||||
dispatched: "text-brand",
|
||||
waiting_local_directory: "text-muted-foreground",
|
||||
running: "text-brand",
|
||||
completed: "text-muted-foreground",
|
||||
failed: "text-destructive",
|
||||
|
||||
@@ -74,7 +74,7 @@ function Text({
|
||||
TextVariantProps & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const textClass = React.useContext(TextClassContext);
|
||||
const textClass = React.use(TextClassContext);
|
||||
const Component = asChild ? Slot : RNText;
|
||||
return (
|
||||
<Component
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
use,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -58,7 +58,7 @@ const RealtimeContext = createContext<WSClient | null>(null);
|
||||
* (cold start, between workspace switches, signed out). Consumers must
|
||||
* guard with `if (!ws) return` in their effect. */
|
||||
export function useWSClient(): WSClient | null {
|
||||
return useContext(RealtimeContext);
|
||||
return use(RealtimeContext);
|
||||
}
|
||||
|
||||
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* `![]()` URL while rendering a comment and pass the array through so
|
||||
* a left/right swipe walks the gallery.
|
||||
*/
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { createContext, use, useState, type ReactNode } from "react";
|
||||
import ImageView from "react-native-image-viewing";
|
||||
|
||||
interface LightboxApi {
|
||||
@@ -24,7 +24,7 @@ const LightboxContext = createContext<LightboxApi>({
|
||||
});
|
||||
|
||||
export function useLightbox(): LightboxApi {
|
||||
return useContext(LightboxContext);
|
||||
return use(LightboxContext);
|
||||
}
|
||||
|
||||
export function LightboxProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
10
apps/web/app/[workspaceSlug]/(dashboard)/billing/page.tsx
Normal file
10
apps/web/app/[workspaceSlug]/(dashboard)/billing/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { BillingTestPage } from "@multica/views/billing";
|
||||
|
||||
// Account-level test page for the cloud-billing API surface. Despite
|
||||
// living under [workspaceSlug] — that's where the dashboard layout
|
||||
// requires every page to sit — none of the data here is workspace-
|
||||
// scoped. The slug just keeps the route inside the authenticated
|
||||
// shell.
|
||||
export default function BillingRoute() {
|
||||
return <BillingTestPage />;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export function FAQSection() {
|
||||
{t.faq.items.map((faq, i) => (
|
||||
<div key={i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenIndex(openIndex === i ? null : i)}
|
||||
className="flex w-full items-start justify-between gap-4 py-6 text-left"
|
||||
>
|
||||
|
||||
@@ -238,6 +238,7 @@ function TeammatesVisual() {
|
||||
<div className="relative">
|
||||
<PropRow label="Status">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"
|
||||
onClick={() => { setStatusOpen(!statusOpen); setPriorityOpen(false); }}
|
||||
>
|
||||
@@ -249,6 +250,7 @@ function TeammatesVisual() {
|
||||
<div className="absolute left-0 top-full z-10 mt-1 w-44 overflow-hidden rounded-md border bg-popover shadow-md">
|
||||
{statusCycle.map((s) => (
|
||||
<button
|
||||
type="button"
|
||||
key={s}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors",
|
||||
@@ -269,6 +271,7 @@ function TeammatesVisual() {
|
||||
<div className="relative">
|
||||
<PropRow label="Priority">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"
|
||||
onClick={() => { setPriorityOpen(!priorityOpen); setStatusOpen(false); }}
|
||||
>
|
||||
@@ -280,6 +283,7 @@ function TeammatesVisual() {
|
||||
<div className="absolute left-0 top-full z-10 mt-1 w-44 overflow-hidden rounded-md border bg-popover shadow-md">
|
||||
{priorityCycle.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors",
|
||||
@@ -299,6 +303,7 @@ function TeammatesVisual() {
|
||||
{/* Assignee — clickable to toggle picker */}
|
||||
<PropRow label="Assignee">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"
|
||||
onClick={() => { setPickerOpen(!pickerOpen); setStatusOpen(false); setPriorityOpen(false); }}
|
||||
>
|
||||
@@ -323,6 +328,7 @@ function TeammatesVisual() {
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors",
|
||||
!assignee.type && "bg-accent",
|
||||
@@ -340,6 +346,7 @@ function TeammatesVisual() {
|
||||
<div className="p-1 pt-0">
|
||||
{allAssignees.filter((a) => a.type === "member").map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs hover:bg-accent transition-colors",
|
||||
@@ -359,6 +366,7 @@ function TeammatesVisual() {
|
||||
<div className="p-1 pt-0">
|
||||
{allAssignees.filter((a) => a.type === "agent").map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-sm px-2 py-1 text-xs hover:bg-accent transition-colors",
|
||||
@@ -446,6 +454,7 @@ function AutonomousVisual() {
|
||||
if (item.type === "thinking") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-info/5 transition-colors"
|
||||
onClick={() => setExpanded(isExpanded ? null : i)}
|
||||
@@ -460,6 +469,7 @@ function AutonomousVisual() {
|
||||
if (item.type === "tool_use") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-info/5 transition-colors"
|
||||
onClick={() => setExpanded(isExpanded ? null : i)}
|
||||
@@ -474,6 +484,7 @@ function AutonomousVisual() {
|
||||
/* tool_result */
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-accent/50 transition-colors"
|
||||
onClick={() => setExpanded(isExpanded ? null : i)}
|
||||
@@ -540,13 +551,14 @@ function SkillsVisual() {
|
||||
<div className="w-[200px] shrink-0 border-r flex flex-col">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-xs font-semibold">Skills</span>
|
||||
<button className="rounded p-0.5 text-muted-foreground hover:bg-accent transition-colors">
|
||||
<button type="button" className="rounded p-0.5 text-muted-foreground hover:bg-accent transition-colors">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden divide-y">
|
||||
{mockSkills.map((skill, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={skill.name}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors",
|
||||
@@ -585,6 +597,7 @@ function SkillsVisual() {
|
||||
<div className="py-1">
|
||||
{mockFileTree.map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.name}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 py-1 text-xs transition-colors",
|
||||
@@ -806,6 +819,7 @@ function RuntimesVisual() {
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{mockRuntimeList.map((rt, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={rt.name}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 px-3 py-2.5 transition-colors",
|
||||
@@ -860,6 +874,7 @@ function RuntimesVisual() {
|
||||
<div className="flex items-center gap-1">
|
||||
{(["7d", "30d", "90d"] as const).map((range) => (
|
||||
<button
|
||||
type="button"
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={cn(
|
||||
@@ -996,6 +1011,7 @@ export function FeaturesSection() {
|
||||
<div className="sticky top-28 flex flex-col gap-0 py-28">
|
||||
{features.map((f, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.label}
|
||||
onClick={() => scrollToPanel(i)}
|
||||
className={cn(
|
||||
|
||||
@@ -100,6 +100,7 @@ export function LandingFooter() {
|
||||
<div className="flex items-center">
|
||||
{locales.map((l, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={l}
|
||||
onClick={() => setLocale(l)}
|
||||
className={cn(
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import {
|
||||
createContext,
|
||||
use,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
@@ -73,7 +73,7 @@ export function LocaleProvider({
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const ctx = useContext(LocaleContext);
|
||||
const ctx = use(LocaleContext);
|
||||
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
label: "RUNTIMES",
|
||||
title: "One dashboard for all your compute",
|
||||
description:
|
||||
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects 11 supported coding tools on your machine.",
|
||||
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects 12 supported coding tools on your machine.",
|
||||
cards: [
|
||||
{
|
||||
title: "Unified runtime panel",
|
||||
@@ -116,7 +116,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "Auto-detection on first run",
|
||||
description:
|
||||
"Multica scans for 11 supported coding tools \u2014 Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi \u2014 and registers a runtime for each one it finds.",
|
||||
"Multica scans for 12 supported coding tools \u2014 Antigravity, Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi \u2014 and registers a runtime for each one it finds.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -136,7 +136,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
description:
|
||||
"Run multica setup \u2014 it walks you through OAuth, starts the daemon, and scans for the 11 supported coding tools (Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi). Whichever ones you already have installed get registered as runtimes automatically.",
|
||||
"Run multica setup \u2014 it walks you through OAuth, starts the daemon, and scans for the 12 supported coding tools (Antigravity, Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi). Whichever ones you already have installed get registered as runtimes automatically.",
|
||||
},
|
||||
{
|
||||
title: "Create your first agent",
|
||||
@@ -192,7 +192,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
question: "What coding agents does Multica support?",
|
||||
answer:
|
||||
"Multica supports 11 coding tools out of the box: Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since it's open source, you can also add your own backends.",
|
||||
"Multica supports 12 coding tools out of the box: Antigravity, Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since it's open source, you can also add your own backends.",
|
||||
},
|
||||
{
|
||||
question: "Do I need to self-host, or is there a cloud version?",
|
||||
@@ -292,6 +292,59 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.11",
|
||||
date: "2026-05-28",
|
||||
title: "Antigravity CLI Support",
|
||||
changes: [],
|
||||
features: [
|
||||
"Antigravity CLI is now a supported coding runtime",
|
||||
"Agent settings now include MCP configuration for Hermes, Kimi, and Kiro",
|
||||
"Self-hosted admins can turn off self-service workspace creation",
|
||||
"Desktop local runtimes can renew access tokens before they expire",
|
||||
],
|
||||
improvements: [
|
||||
"Helm charts can be published to GHCR, and email setup docs are clearer",
|
||||
"Task transcripts show shorter, safer working-folder labels",
|
||||
"New Issues stay at the top in manual boards, and deleted Issues stay out of recents",
|
||||
"Local runtime machines are grouped by device name",
|
||||
],
|
||||
fixes: [
|
||||
"Terminal task completion retries brief callback failures",
|
||||
"Local-directory runs preserve existing CLAUDE.md, AGENTS.md, and GEMINI.md files",
|
||||
"Windows Pi runs keep multi-line prompts intact",
|
||||
"Provider logos render consistently",
|
||||
"Daemon cleanup skips incomplete parent-task metadata",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.10",
|
||||
date: "2026-05-27",
|
||||
title: "Local Working Directories",
|
||||
changes: [],
|
||||
features: [
|
||||
"Projects can now use a local working directory on Desktop, so tasks can run in an existing folder while Multica shows when another task is waiting for that directory",
|
||||
"Autopilot webhook triggers can now filter incoming events and actions before work starts, with docs linked directly from the setup flow",
|
||||
"Swimlane views can group Issues by parent Issue, project, or assignee, making large boards easier to slice by how the team plans work",
|
||||
"Comments now support selecting multiple attachments and keeping, removing, or replacing attachments while editing",
|
||||
],
|
||||
improvements: [
|
||||
"Chinese product copy is more consistent across navigation, settings, search, and runtime screens",
|
||||
"The frontend codebase received accessibility and React cleanup across common screens, and mobile checks now run only when mobile code changes",
|
||||
"CLI list output is cleaner for automated readers, and squad lists now show member counts when available",
|
||||
],
|
||||
fixes: [
|
||||
"Swimlane lanes no longer appear empty when nested Issues load beyond the first page",
|
||||
"Creating a sub-Issue with an intelligent agent now preserves the parent Issue relationship",
|
||||
"Scheduled autopilot titles and descriptions now use the trigger's configured timezone",
|
||||
"Comment editors avoid duplicate live-update flashes, keep text after failed sends, and apply mention behavior consistently when edited",
|
||||
"Code blocks without a detected language now render their text instead of an empty block",
|
||||
"Desktop and web avatars now resolve relative upload paths correctly",
|
||||
"Codex, Cursor, and Hermes runtime handling received fixes for Desktop discovery, command arguments, usage attribution, and clearer stalled-run diagnostics",
|
||||
"Private intelligent agents can no longer be triggered by plain comments from users who cannot access them",
|
||||
"Several UI details were cleaned up across GitHub settings, project creation, board sorting, and agent skill screens",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.9",
|
||||
date: "2026-05-26",
|
||||
|
||||
@@ -101,7 +101,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
label: "\u8fd0\u884c\u65f6",
|
||||
title: "\u4e00\u4e2a\u63a7\u5236\u53f0\u7ba1\u7406\u6240\u6709\u7b97\u529b",
|
||||
description:
|
||||
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u3002",
|
||||
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 12 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u3002",
|
||||
cards: [
|
||||
{
|
||||
title: "\u7edf\u4e00\u8fd0\u884c\u65f6\u9762\u677f",
|
||||
@@ -116,7 +116,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "\u9996\u6b21\u542f\u52a8\u81ea\u52a8\u6ce8\u518c",
|
||||
description:
|
||||
"Multica \u626b\u63cf\u672c\u673a\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u2014\u2014Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u2014\u2014\u5e76\u4e3a\u6bcf\u6b3e\u5df2\u5b89\u88c5\u7684\u5de5\u5177\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002",
|
||||
"Multica \u626b\u63cf\u672c\u673a\u7684 12 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u2014\u2014Antigravity\u3001Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u2014\u2014\u5e76\u4e3a\u6bcf\u6b3e\u5df2\u5b89\u88c5\u7684\u5de5\u5177\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -136,7 +136,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
|
||||
description:
|
||||
"运行 multica setup——它会引导你完成 OAuth 登录、启动守护进程、并扫描 11 款支持的 AI 编程工具(Claude Code、Codex、Cursor、Copilot、Gemini、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi)。本机已安装的工具会被自动注册成运行时。",
|
||||
"运行 multica setup——它会引导你完成 OAuth 登录、启动守护进程、并扫描 12 款支持的 AI 编程工具(Antigravity、Claude Code、Codex、Cursor、Copilot、Gemini、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi)。本机已安装的工具会被自动注册成运行时。",
|
||||
},
|
||||
{
|
||||
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a 智能体",
|
||||
@@ -192,7 +192,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 智能体\uff1f",
|
||||
answer:
|
||||
"Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 11 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aClaude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
|
||||
"Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 12 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aAntigravity\u3001Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
|
||||
},
|
||||
{
|
||||
question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f",
|
||||
@@ -292,6 +292,59 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.11",
|
||||
date: "2026-05-28",
|
||||
title: "Antigravity CLI 支持",
|
||||
changes: [],
|
||||
features: [
|
||||
"Antigravity CLI 现在可作为编码运行环境使用",
|
||||
"智能体详情页新增 MCP 配置,可用于 Hermes、Kimi 和 Kiro",
|
||||
"自托管管理员可以关闭自助创建工作区",
|
||||
"桌面端本机运行环境会在访问令牌过期前自动续期",
|
||||
],
|
||||
improvements: [
|
||||
"Helm Chart 可发布到 GHCR,邮件配置文档更清晰",
|
||||
"任务记录会显示更短、更安全的工作目录",
|
||||
"手动排序时,新 Issue 会留在列顶部,已删除 Issue 不再回到最近列表",
|
||||
"本机运行环境会按设备名合并同一台机器",
|
||||
],
|
||||
fixes: [
|
||||
"任务完成回传遇到短暂错误时会重试",
|
||||
"本地目录运行不会覆盖已有的 CLAUDE.md、AGENTS.md 或 GEMINI.md",
|
||||
"Windows 上的 Pi 会保留多行提示词",
|
||||
"运行环境 Logo 显示更稳定",
|
||||
"本机运行服务清理时会跳过不完整父级信息",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.10",
|
||||
date: "2026-05-27",
|
||||
title: "本地工作目录",
|
||||
changes: [],
|
||||
features: [
|
||||
"项目现在可以在桌面端绑定本地工作目录,智能体任务会直接在现有文件夹中运行,并在目录被占用时显示等待状态",
|
||||
"自动任务的 Webhook 触发器可以按事件和动作先过滤请求,配置界面也可以直接打开对应文档",
|
||||
"泳道视图可以按父 Issue、项目或负责人分组,大型看板更容易按团队计划方式查看",
|
||||
"评论现在支持一次选择多个附件,也可以在编辑时保留、移除或替换附件",
|
||||
],
|
||||
improvements: [
|
||||
"中文界面文案在导航、设置、搜索和运行时页面里更一致",
|
||||
"常用界面做了无障碍和 React 清理,移动端检查也只在移动端代码变化时运行",
|
||||
"命令行列表输出对自动化读取更安静,小队列表也会显示可用的成员数量",
|
||||
],
|
||||
fixes: [
|
||||
"泳道里超过第一页才加载到的嵌套 Issue 不再显示为空",
|
||||
"从子 Issue 入口切到用智能体创建时,会继续保留父 Issue 关系",
|
||||
"定时自动任务生成标题和描述时,会按触发器配置的时区显示日期",
|
||||
"评论输入框不再因为实时更新闪出重复内容,发送失败后会保留文字,编辑评论时也会正确处理提及",
|
||||
"没有识别出语言的代码块也会正常显示文本,不再出现空白代码块",
|
||||
"桌面端和网页端的头像现在可以正确加载相对路径上传文件",
|
||||
"Codex、Cursor 和 Hermes 运行处理修复了桌面端发现、命令参数、用量归属和卡住诊断提示",
|
||||
"无权限访问私有智能体的用户,不能再用普通评论触发它们",
|
||||
"GitHub 设置、项目创建、看板排序和智能体技能页面清理了若干界面细节",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.9",
|
||||
date: "2026-05-26",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{- $backendImageTag := default .Chart.AppVersion .Values.images.backend.tag -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
@@ -52,7 +53,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: "{{ .Values.images.backend.repository }}:{{ .Values.images.backend.tag }}"
|
||||
image: "{{ .Values.images.backend.repository }}:{{ $backendImageTag }}"
|
||||
imagePullPolicy: {{ .Values.images.backend.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
|
||||
@@ -16,6 +16,7 @@ data:
|
||||
ALLOW_SIGNUP: {{ .Values.backend.config.allowSignup | quote }}
|
||||
ALLOWED_EMAILS: {{ .Values.backend.config.allowedEmails | quote }}
|
||||
ALLOWED_EMAIL_DOMAINS: {{ .Values.backend.config.allowedEmailDomains | quote }}
|
||||
DISABLE_WORKSPACE_CREATION: {{ .Values.backend.config.disableWorkspaceCreation | quote }}
|
||||
GOOGLE_CLIENT_ID: {{ .Values.backend.config.googleClientId | quote }}
|
||||
GOOGLE_REDIRECT_URI: {{ .Values.backend.config.googleRedirectUri | quote }}
|
||||
S3_BUCKET: {{ .Values.backend.config.s3Bucket | quote }}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{- $frontendImageTag := default .Chart.AppVersion .Values.images.frontend.tag -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@@ -20,7 +21,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: "{{ .Values.images.frontend.repository }}:{{ .Values.images.frontend.tag }}"
|
||||
image: "{{ .Values.images.frontend.repository }}:{{ $frontendImageTag }}"
|
||||
imagePullPolicy: {{ .Values.images.frontend.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
images:
|
||||
backend:
|
||||
repository: ghcr.io/multica-ai/multica-backend
|
||||
tag: latest
|
||||
# Empty defaults to Chart.appVersion. Released OCI charts set appVersion to
|
||||
# the Git tag (for example v0.3.5), so installs use matching app images.
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
frontend:
|
||||
repository: ghcr.io/multica-ai/multica-web
|
||||
tag: latest
|
||||
# Empty defaults to Chart.appVersion. Set explicitly to override.
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
postgres:
|
||||
repository: pgvector/pgvector
|
||||
@@ -69,6 +72,10 @@ backend:
|
||||
allowSignup: true
|
||||
allowedEmails: ""
|
||||
allowedEmailDomains: ""
|
||||
# Self-host gate (#3433): set true to make POST /api/workspaces 403 for
|
||||
# every caller. Bootstrap the workspace with this false, then flip to
|
||||
# true so users can only join via invitation.
|
||||
disableWorkspaceCreation: false
|
||||
googleClientId: ""
|
||||
googleRedirectUri: http://multica.dev.lan/auth/callback
|
||||
s3Bucket: ""
|
||||
|
||||
@@ -72,6 +72,7 @@ services:
|
||||
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
||||
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
DISABLE_WORKSPACE_CREATION: ${DISABLE_WORKSPACE_CREATION:-}
|
||||
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
|
||||
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
|
||||
# Public URL the API is reachable at from the open internet, no
|
||||
|
||||
@@ -171,6 +171,21 @@ describe("deriveWorkloadDetail", () => {
|
||||
expect(r.queuedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("counts waiting_local_directory as queued (daemon parked on a path lock)", () => {
|
||||
// waiting_local_directory is the daemon-side "blocked on a busy
|
||||
// local_directory" hold state. It is still on the agent's plate —
|
||||
// the chip must not flip to idle just because the run phase hasn't
|
||||
// started yet — and is grouped with queued/dispatched in the
|
||||
// presence verdict so the user sees "Queued · 1" rather than
|
||||
// "Idle".
|
||||
const r = deriveWorkloadDetail([
|
||||
makeTask({ status: "waiting_local_directory" }),
|
||||
]);
|
||||
expect(r.workload).toBe("queued");
|
||||
expect(r.runningCount).toBe(0);
|
||||
expect(r.queuedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns working when running coexists with queued (overflow)", () => {
|
||||
// Capacity-saturated agent: still running, but with a queue building.
|
||||
// The chip says "Working" with the queue expressed as a `+Nq` badge.
|
||||
|
||||
@@ -62,7 +62,14 @@ export function deriveWorkloadDetail(tasks: readonly AgentTask[]): WorkloadDetai
|
||||
for (const t of tasks) {
|
||||
if (t.status === "running") {
|
||||
runningCount += 1;
|
||||
} else if (t.status === "queued" || t.status === "dispatched") {
|
||||
} else if (
|
||||
t.status === "queued" ||
|
||||
t.status === "dispatched" ||
|
||||
// The daemon parked this task on a busy local_directory path. It's
|
||||
// still on the agent's plate (counts toward "queued" presence), but
|
||||
// it hasn't reached the run phase yet.
|
||||
t.status === "waiting_local_directory"
|
||||
) {
|
||||
queuedCount += 1;
|
||||
}
|
||||
// Terminal statuses (completed / failed / cancelled) intentionally
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from "./use-workspace-presence-prefetch";
|
||||
export * from "./constants";
|
||||
export * from "./visibility-label";
|
||||
export * from "./use-workspace-agent-availability";
|
||||
export * from "./mcp-support";
|
||||
|
||||
11
packages/core/agents/mcp-support.ts
Normal file
11
packages/core/agents/mcp-support.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// The set of runtime providers whose backend reads `agent.mcp_config` and
|
||||
// forwards MCP servers to the underlying CLI. The MCP config tab is hidden
|
||||
// for every other provider so a user can't save a value the runtime will
|
||||
// silently ignore. Keep this list in sync with the backends in
|
||||
// `server/pkg/agent/` that read `ExecOptions.McpConfig`.
|
||||
const MCP_SUPPORTED_PROVIDERS = new Set(["claude", "codex"]);
|
||||
|
||||
export function providerSupportsMcpConfig(provider: string | undefined | null): boolean {
|
||||
if (!provider) return false;
|
||||
return MCP_SUPPORTED_PROVIDERS.has(provider);
|
||||
}
|
||||
@@ -152,7 +152,7 @@ describe("ApiClient", () => {
|
||||
expect(headers["X-Client-OS"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the Cloud Runtime node API contract and forwards bootstrap PAT on create", async () => {
|
||||
it("uses the Cloud Runtime node API contract", async () => {
|
||||
const node = {
|
||||
id: "node-1",
|
||||
owner_id: "user-1",
|
||||
@@ -195,7 +195,6 @@ describe("ApiClient", () => {
|
||||
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",
|
||||
);
|
||||
@@ -206,7 +205,6 @@ describe("ApiClient", () => {
|
||||
name: "gpu-dev-01",
|
||||
}),
|
||||
});
|
||||
expect((createCall[1]!.headers as Record<string, string>)["X-User-PAT"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back when Cloud Runtime node responses drift", async () => {
|
||||
|
||||
@@ -70,6 +70,7 @@ import type {
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
CreateProjectResourceRequest,
|
||||
UpdateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
Label,
|
||||
CreateLabelRequest,
|
||||
@@ -101,6 +102,15 @@ import type {
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
BillingBalance,
|
||||
BillingTransactionsPage,
|
||||
BillingBatchesPage,
|
||||
BillingTopupsPage,
|
||||
BillingPriceTier,
|
||||
CreateBillingCheckoutSessionRequest,
|
||||
CreateBillingCheckoutSessionResponse,
|
||||
BillingCheckoutSessionStatus,
|
||||
CreateBillingPortalSessionResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import type {
|
||||
@@ -154,6 +164,22 @@ import {
|
||||
TimelineEntriesSchema,
|
||||
UserSchema,
|
||||
WebhookDeliveryResponseSchema,
|
||||
BillingBalanceSchema,
|
||||
BillingTransactionsPageSchema,
|
||||
BillingBatchesPageSchema,
|
||||
BillingTopupsPageSchema,
|
||||
BillingPriceTierListSchema,
|
||||
CreateBillingCheckoutSessionResponseSchema,
|
||||
BillingCheckoutSessionStatusSchema,
|
||||
CreateBillingPortalSessionResponseSchema,
|
||||
EMPTY_BILLING_BALANCE,
|
||||
EMPTY_BILLING_TRANSACTIONS_PAGE,
|
||||
EMPTY_BILLING_BATCHES_PAGE,
|
||||
EMPTY_BILLING_TOPUPS_PAGE,
|
||||
EMPTY_BILLING_PRICE_TIER_LIST,
|
||||
EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
|
||||
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
|
||||
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
@@ -525,6 +551,7 @@ export class ApiClient {
|
||||
squad_id?: string;
|
||||
prompt: string;
|
||||
project_id?: string | null;
|
||||
parent_issue_id?: string | null;
|
||||
}): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
@@ -557,6 +584,19 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Batched variant — returns children for multiple parents in one request.
|
||||
* Avoids an N-request fan-out in Swimlane (one per visible parent lane).
|
||||
* parentIds must be non-empty; pass a sorted, deduplicated list so the
|
||||
* React Query cache key is stable across renders. */
|
||||
async listChildrenByParents(parentIds: string[]): Promise<{ issues: Issue[] }> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/issues/children?parent_ids=${parentIds.join(",")}`,
|
||||
);
|
||||
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
|
||||
endpoint: "GET /api/issues/children",
|
||||
});
|
||||
}
|
||||
|
||||
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
|
||||
return this.fetch("/api/issues/child-progress");
|
||||
}
|
||||
@@ -849,6 +889,135 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Cloud Billing — proxies to multica-cloud /api/v1/billing/*. The
|
||||
// multica-api server stamps X-User-ID and forwards bytes; everything
|
||||
// here is upstream-shaped. See packages/core/types/billing.ts for the
|
||||
// response field documentation.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async getCloudBillingBalance(): Promise<BillingBalance> {
|
||||
const raw = await this.fetch<unknown>("/api/cloud-billing/balance");
|
||||
return parseWithFallback(raw, BillingBalanceSchema, EMPTY_BILLING_BALANCE, {
|
||||
endpoint: "GET /api/cloud-billing/balance",
|
||||
});
|
||||
}
|
||||
|
||||
async listCloudBillingTransactions(
|
||||
params?: { page?: number; page_size?: number },
|
||||
): Promise<BillingTransactionsPage> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.page !== undefined) search.set("page", String(params.page));
|
||||
if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
|
||||
const query = search.toString();
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/cloud-billing/transactions${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
BillingTransactionsPageSchema,
|
||||
EMPTY_BILLING_TRANSACTIONS_PAGE,
|
||||
{ endpoint: "GET /api/cloud-billing/transactions" },
|
||||
);
|
||||
}
|
||||
|
||||
async listCloudBillingBatches(
|
||||
params?: { page?: number; page_size?: number },
|
||||
): Promise<BillingBatchesPage> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.page !== undefined) search.set("page", String(params.page));
|
||||
if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
|
||||
const query = search.toString();
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/cloud-billing/batches${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
BillingBatchesPageSchema,
|
||||
EMPTY_BILLING_BATCHES_PAGE,
|
||||
{ endpoint: "GET /api/cloud-billing/batches" },
|
||||
);
|
||||
}
|
||||
|
||||
async listCloudBillingTopups(
|
||||
params?: { page?: number; page_size?: number },
|
||||
): Promise<BillingTopupsPage> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.page !== undefined) search.set("page", String(params.page));
|
||||
if (params?.page_size !== undefined) search.set("page_size", String(params.page_size));
|
||||
const query = search.toString();
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/cloud-billing/topups${query ? `?${query}` : ""}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
BillingTopupsPageSchema,
|
||||
EMPTY_BILLING_TOPUPS_PAGE,
|
||||
{ endpoint: "GET /api/cloud-billing/topups" },
|
||||
);
|
||||
}
|
||||
|
||||
async listCloudBillingPriceTiers(): Promise<BillingPriceTier[]> {
|
||||
const raw = await this.fetch<unknown>("/api/cloud-billing/price-tiers");
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
BillingPriceTierListSchema,
|
||||
EMPTY_BILLING_PRICE_TIER_LIST,
|
||||
{ endpoint: "GET /api/cloud-billing/price-tiers" },
|
||||
);
|
||||
}
|
||||
|
||||
async createCloudBillingCheckoutSession(
|
||||
data: CreateBillingCheckoutSessionRequest,
|
||||
): Promise<CreateBillingCheckoutSessionResponse> {
|
||||
const res = await this.fetchRaw("/api/cloud-billing/checkout-sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
extraHeaders: { "Content-Type": "application/json" },
|
||||
});
|
||||
const raw = (await res.json()) as unknown;
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CreateBillingCheckoutSessionResponseSchema,
|
||||
EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
|
||||
{ endpoint: "POST /api/cloud-billing/checkout-sessions" },
|
||||
);
|
||||
}
|
||||
|
||||
async getCloudBillingCheckoutSession(
|
||||
sessionId: string,
|
||||
): Promise<BillingCheckoutSessionStatus> {
|
||||
// Stripe session ids are `cs_<base62>` so they're URL-safe by
|
||||
// construction; encodeURIComponent is paranoia for the case where a
|
||||
// future Stripe format change adds a non-alphanumeric character. The
|
||||
// server has its own allow-list rejection for unsafe ids.
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/cloud-billing/checkout-sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
BillingCheckoutSessionStatusSchema,
|
||||
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
|
||||
{ endpoint: "GET /api/cloud-billing/checkout-sessions/{sessionId}" },
|
||||
);
|
||||
}
|
||||
|
||||
async createCloudBillingPortalSession(): Promise<CreateBillingPortalSessionResponse> {
|
||||
const res = await this.fetchRaw("/api/cloud-billing/portal-sessions", {
|
||||
method: "POST",
|
||||
// Body is intentionally absent — the upstream endpoint requires no
|
||||
// payload today. fetchRaw with no body skips the Content-Type
|
||||
// default; that's fine because there's nothing to declare.
|
||||
});
|
||||
const raw = (await res.json()) as unknown;
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CreateBillingPortalSessionResponseSchema,
|
||||
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
|
||||
{ endpoint: "POST /api/cloud-billing/portal-sessions" },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRuntime(runtimeId: string): Promise<void> {
|
||||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -1197,6 +1366,9 @@ export class ApiClient {
|
||||
posthog_key?: string;
|
||||
posthog_host?: string;
|
||||
analytics_environment?: string;
|
||||
// Self-host gate (#3433). Optional because older servers omit the field
|
||||
// entirely; consumers must default to false.
|
||||
workspace_creation_disabled?: boolean;
|
||||
}> {
|
||||
return this.fetch("/api/config");
|
||||
}
|
||||
@@ -1549,6 +1721,17 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateProjectResource(
|
||||
projectId: string,
|
||||
resourceId: string,
|
||||
data: UpdateProjectResourceRequest,
|
||||
): Promise<ProjectResource> {
|
||||
return this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProjectResource(
|
||||
projectId: string,
|
||||
resourceId: string,
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
Attachment,
|
||||
BillingBalance,
|
||||
BillingBatchesPage,
|
||||
BillingCheckoutSessionStatus,
|
||||
BillingPriceTier,
|
||||
BillingTopupsPage,
|
||||
BillingTransactionsPage,
|
||||
CreateAgentFromTemplateResponse,
|
||||
CreateBillingCheckoutSessionResponse,
|
||||
CreateBillingPortalSessionResponse,
|
||||
GroupedIssuesResponse,
|
||||
ListIssuesResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
@@ -655,3 +663,173 @@ export const EMPTY_USER: User = {
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Billing schemas (cloud-billing proxy surface)
|
||||
//
|
||||
// All billing JSON we receive comes from multica-cloud verbatim — we proxy
|
||||
// the bytes without re-shaping. These schemas use `loose()` so a future
|
||||
// non-breaking field addition on the cloud side doesn't crash us; required
|
||||
// fields are still strictly enforced. EMPTY_* constants supply the
|
||||
// fallback parseWithFallback uses when the upstream response is malformed
|
||||
// or unparseable.
|
||||
|
||||
export const BillingBalanceSchema = z.object({
|
||||
owner_id: z.string(),
|
||||
balance_micro: z.number(),
|
||||
balance_credit: z.number(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_BILLING_BALANCE: BillingBalance = {
|
||||
owner_id: "",
|
||||
balance_micro: 0,
|
||||
balance_credit: 0,
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
// `tx_type` and `source` are kept as plain strings here; the cloud doc
|
||||
// enumerates the canonical values but the frontend display tolerates
|
||||
// unknown ones gracefully. Strict enums would crash the page on a future
|
||||
// addition (e.g. a new `topup` source kind).
|
||||
export const BillingTransactionSchema = z.object({
|
||||
id: z.string(),
|
||||
owner_id: z.string(),
|
||||
idempotency_key: z.string().default(""),
|
||||
tx_type: z.string(),
|
||||
source: z.string(),
|
||||
amount_micro: z.number(),
|
||||
balance_after: z.number(),
|
||||
reference_id: z.string().default(""),
|
||||
description: z.string().default(""),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
created_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const BillingTransactionsPageSchema = z.object({
|
||||
items: z.array(BillingTransactionSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
page: z.number().default(1),
|
||||
page_size: z.number().default(20),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_BILLING_TRANSACTIONS_PAGE: BillingTransactionsPage = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
};
|
||||
|
||||
export const BillingBatchSchema = z.object({
|
||||
id: z.string(),
|
||||
owner_id: z.string(),
|
||||
source_tx_id: z.string().default(""),
|
||||
source_type: z.string(),
|
||||
total_micro: z.number(),
|
||||
remaining_micro: z.number(),
|
||||
// Cloud either omits the key (never expires) or sends a string
|
||||
// timestamp. Null is also tolerated since some serializers emit
|
||||
// explicit nulls for absent timestamps.
|
||||
expires_at: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const BillingBatchesPageSchema = z.object({
|
||||
items: z.array(BillingBatchSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
page: z.number().default(1),
|
||||
page_size: z.number().default(20),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_BILLING_BATCHES_PAGE: BillingBatchesPage = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
};
|
||||
|
||||
export const BillingTopupSchema = z.object({
|
||||
id: z.string(),
|
||||
owner_id: z.string(),
|
||||
amount_cents: z.number(),
|
||||
currency: z.string().default("usd"),
|
||||
credits: z.number(),
|
||||
bonus_credits: z.number().default(0),
|
||||
status: z.string(),
|
||||
tier_id: z.string().default(""),
|
||||
stripe_checkout_id: z.string().default(""),
|
||||
// Only set after status reaches `credited` — leave optional rather
|
||||
// than coerce to "" so a UI can branch on existence.
|
||||
purchase_batch_id: z.string().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const BillingTopupsPageSchema = z.object({
|
||||
items: z.array(BillingTopupSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
page: z.number().default(1),
|
||||
page_size: z.number().default(20),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_BILLING_TOPUPS_PAGE: BillingTopupsPage = {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
};
|
||||
|
||||
export const BillingPriceTierSchema = z.object({
|
||||
id: z.string(),
|
||||
// Cloud doc says display_name falls back to id; tolerate empty too.
|
||||
display_name: z.string().default(""),
|
||||
amount_cents: z.number(),
|
||||
credits: z.number(),
|
||||
bonus_credits: z.number().optional(),
|
||||
bonus_expires_in: z.string().optional(),
|
||||
}).loose();
|
||||
|
||||
export const BillingPriceTierListSchema = z.array(BillingPriceTierSchema);
|
||||
|
||||
export const EMPTY_BILLING_PRICE_TIER_LIST: BillingPriceTier[] = [];
|
||||
|
||||
export const CreateBillingCheckoutSessionResponseSchema = z.object({
|
||||
order_id: z.string(),
|
||||
session_id: z.string(),
|
||||
url: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE: CreateBillingCheckoutSessionResponse = {
|
||||
order_id: "",
|
||||
session_id: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const BillingCheckoutSessionStatusSchema = z.object({
|
||||
order_id: z.string(),
|
||||
status: z.string(),
|
||||
amount_cents: z.number(),
|
||||
credits: z.number(),
|
||||
bonus_credits: z.number().default(0),
|
||||
currency: z.string().default("usd"),
|
||||
tier_id: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_BILLING_CHECKOUT_SESSION_STATUS: BillingCheckoutSessionStatus = {
|
||||
order_id: "",
|
||||
status: "pending",
|
||||
amount_cents: 0,
|
||||
credits: 0,
|
||||
bonus_credits: 0,
|
||||
currency: "usd",
|
||||
tier_id: "",
|
||||
};
|
||||
|
||||
export const CreateBillingPortalSessionResponseSchema = z.object({
|
||||
url: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE: CreateBillingPortalSessionResponse = {
|
||||
url: "",
|
||||
};
|
||||
|
||||
2
packages/core/billing/index.ts
Normal file
2
packages/core/billing/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
79
packages/core/billing/mutations.ts
Normal file
79
packages/core/billing/mutations.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { CreateBillingCheckoutSessionRequest } from "../types";
|
||||
import { billingKeys } from "./queries";
|
||||
|
||||
// Both mutations here trigger a hop OUT of the SPA — Stripe Checkout
|
||||
// and Stripe Billing Portal are hosted pages. The mutation completes
|
||||
// once the URL is in our hands; the caller is responsible for the
|
||||
// `window.location.href = url` redirect (or in newer flows,
|
||||
// `window.open` + tab-aware polling).
|
||||
//
|
||||
// We invalidate the topup list on settle so when the user returns
|
||||
// from Stripe the new `pending` order shows up immediately. The
|
||||
// balance and transactions are NOT invalidated here — they only flip
|
||||
// after Stripe + the cloud webhook actually credit, which is a
|
||||
// post-redirect concern.
|
||||
|
||||
export function useCreateCloudBillingCheckoutSession() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateBillingCheckoutSessionRequest) =>
|
||||
api.createCloudBillingCheckoutSession(data),
|
||||
onSettled: () => {
|
||||
// The new pending topup row is visible to the topups list as
|
||||
// soon as Cloud writes it. Invalidate so the user sees the new
|
||||
// pending entry without a page refresh.
|
||||
qc.invalidateQueries({ queryKey: [...billingKeys.all(), "topups"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCloudBillingPortalSession() {
|
||||
return useMutation({
|
||||
mutationFn: () => api.createCloudBillingPortalSession(),
|
||||
// No cache invalidation — the portal opens, the user does whatever,
|
||||
// and any state changes Stripe-side propagate back via webhook.
|
||||
// The next React Query refetch picks them up at its own cadence.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* useInvalidateBillingDataAfterCredit returns a callback that flushes
|
||||
* the cached balance / transactions / batches / topups so the page
|
||||
* re-fetches them. Used by the Stripe-success polling banner: once it
|
||||
* detects the topup status flipped to a terminal value (credited /
|
||||
* failed / canceled), the banner is the only query that's been
|
||||
* polling — every other card on the page is still showing its
|
||||
* pre-checkout snapshot. Without this invalidation the user sees
|
||||
* "Final status: credited" while the balance card still displays the
|
||||
* old number until they click refresh.
|
||||
*
|
||||
* Scope of the invalidation:
|
||||
*
|
||||
* - balance + transactions + batches: only ever change at the
|
||||
* `credited` transition (the cloud writes the credit ledger and
|
||||
* batch row in the same DB transaction as the wallet update).
|
||||
* For `failed` / `canceled` they do NOT change, so technically we
|
||||
* over-fetch in those cases — three extra cheap round-trips that
|
||||
* simplify the call site and are negligible on a test page.
|
||||
*
|
||||
* - topups: changes on every terminal transition (the order row
|
||||
* flips status), so it always needs invalidating.
|
||||
*
|
||||
* - the checkout-session query itself is intentionally NOT in this
|
||||
* sweep. Its `refetchInterval` already returned `false` when
|
||||
* status went terminal; refetching would just confirm the same
|
||||
* value we already hold and wake the polling cycle back up for
|
||||
* no benefit.
|
||||
*/
|
||||
export function useInvalidateBillingDataAfterCredit() {
|
||||
const qc = useQueryClient();
|
||||
return useCallback(() => {
|
||||
qc.invalidateQueries({ queryKey: billingKeys.balance() });
|
||||
qc.invalidateQueries({ queryKey: [...billingKeys.all(), "transactions"] });
|
||||
qc.invalidateQueries({ queryKey: [...billingKeys.all(), "batches"] });
|
||||
qc.invalidateQueries({ queryKey: [...billingKeys.all(), "topups"] });
|
||||
}, [qc]);
|
||||
}
|
||||
108
packages/core/billing/queries.ts
Normal file
108
packages/core/billing/queries.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
// Billing data is account-level (single owner per X-User-ID), so the
|
||||
// React Query keys are NOT scoped to a workspace — keying on workspace
|
||||
// would force a refetch every time the user navigates to a different
|
||||
// workspace, even though the backing data is identical.
|
||||
//
|
||||
// Query-level staleness: the cloud's billing module is the source of
|
||||
// truth. Topup status flips from `pending` → `paid` → `credited` after
|
||||
// Stripe + the cloud-side webhook handler do their thing, so a returning-
|
||||
// from-Stripe page needs prompt freshness. We rely on the page-level
|
||||
// hooks (refetchInterval / invalidate-on-mount) rather than baking a
|
||||
// stale-time here, so consumers can tune polling per surface.
|
||||
|
||||
export const billingKeys = {
|
||||
all: () => ["billing"] as const,
|
||||
balance: () => [...billingKeys.all(), "balance"] as const,
|
||||
transactions: (params?: { page?: number; page_size?: number }) =>
|
||||
[...billingKeys.all(), "transactions", params ?? {}] as const,
|
||||
batches: (params?: { page?: number; page_size?: number }) =>
|
||||
[...billingKeys.all(), "batches", params ?? {}] as const,
|
||||
topups: (params?: { page?: number; page_size?: number }) =>
|
||||
[...billingKeys.all(), "topups", params ?? {}] as const,
|
||||
priceTiers: () => [...billingKeys.all(), "price-tiers"] as const,
|
||||
checkoutSession: (sessionId: string) =>
|
||||
[...billingKeys.all(), "checkout-session", sessionId] as const,
|
||||
};
|
||||
|
||||
export function billingBalanceOptions() {
|
||||
return queryOptions({
|
||||
queryKey: billingKeys.balance(),
|
||||
queryFn: () => api.getCloudBillingBalance(),
|
||||
// 30s stale-time: balance changes only when a topup credits or a
|
||||
// deduction happens. For the test page the user's main interest is
|
||||
// post-checkout state; we let the page invalidate explicitly.
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function billingTransactionsOptions(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) {
|
||||
return queryOptions({
|
||||
queryKey: billingKeys.transactions(params),
|
||||
queryFn: () => api.listCloudBillingTransactions(params),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function billingBatchesOptions(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) {
|
||||
return queryOptions({
|
||||
queryKey: billingKeys.batches(params),
|
||||
queryFn: () => api.listCloudBillingBatches(params),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function billingTopupsOptions(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) {
|
||||
return queryOptions({
|
||||
queryKey: billingKeys.topups(params),
|
||||
queryFn: () => api.listCloudBillingTopups(params),
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function billingPriceTiersOptions() {
|
||||
return queryOptions({
|
||||
queryKey: billingKeys.priceTiers(),
|
||||
queryFn: () => api.listCloudBillingPriceTiers(),
|
||||
// Price tiers come from server config and basically never change at
|
||||
// runtime — once we've fetched once we can keep it for the whole
|
||||
// session. 5 minutes is more than enough for a test page.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Stripe-success-redirect polling: when the page loads with
|
||||
// `?session_id=...` in the URL, the user just came back from Stripe and
|
||||
// the topup is racing through `pending → paid → credited`. Poll until
|
||||
// it's terminal so the UI can show a final outcome before the user
|
||||
// closes the tab.
|
||||
//
|
||||
// Caller is expected to short-circuit by passing `enabled: !!sessionId`
|
||||
// — that's why we don't gate inside queryOptions itself.
|
||||
export function billingCheckoutSessionOptions(sessionId: string) {
|
||||
return queryOptions({
|
||||
queryKey: billingKeys.checkoutSession(sessionId),
|
||||
queryFn: () => api.getCloudBillingCheckoutSession(sessionId),
|
||||
// Refetch every 2s while we're still in a non-terminal state,
|
||||
// stop once we land in `credited` / `failed` / `canceled`.
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (status === "credited" || status === "failed" || status === "canceled") {
|
||||
return false;
|
||||
}
|
||||
return 2000;
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -5,17 +5,26 @@ interface ConfigState {
|
||||
cdnDomain: string;
|
||||
allowSignup: boolean;
|
||||
googleClientId: string;
|
||||
// Self-host gate (#3433): when true, every "Create workspace" affordance
|
||||
// must be hidden. Defaults to false so unknown / older servers behave like
|
||||
// the managed-cloud case.
|
||||
workspaceCreationDisabled: boolean;
|
||||
setCdnDomain: (domain: string) => void;
|
||||
setAuthConfig: (config: { allowSignup: boolean; googleClientId?: string }) => void;
|
||||
setAuthConfig: (config: {
|
||||
allowSignup: boolean;
|
||||
googleClientId?: string;
|
||||
workspaceCreationDisabled?: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const configStore = createStore<ConfigState>((set) => ({
|
||||
cdnDomain: "",
|
||||
allowSignup: true,
|
||||
googleClientId: "",
|
||||
workspaceCreationDisabled: false,
|
||||
setCdnDomain: (domain) => set({ cdnDomain: domain }),
|
||||
setAuthConfig: ({ allowSignup, googleClientId = "" }) =>
|
||||
set({ allowSignup, googleClientId }),
|
||||
setAuthConfig: ({ allowSignup, googleClientId = "", workspaceCreationDisabled = false }) =>
|
||||
set({ allowSignup, googleClientId, workspaceCreationDisabled }),
|
||||
}));
|
||||
|
||||
export function useConfigStore(): ConfigState;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { createContext, use, type ReactNode } from "react";
|
||||
import type { LocaleAdapter } from "./types";
|
||||
|
||||
const LocaleAdapterContext = createContext<LocaleAdapter | null>(null);
|
||||
@@ -20,7 +20,7 @@ export function LocaleAdapterProvider({
|
||||
}
|
||||
|
||||
export function useLocaleAdapter(): LocaleAdapter {
|
||||
const ctx = useContext(LocaleAdapterContext);
|
||||
const ctx = use(LocaleAdapterContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useLocaleAdapter must be used within <LocaleAdapterProvider>",
|
||||
|
||||
50
packages/core/issues/delete-cache.test.ts
Normal file
50
packages/core/issues/delete-cache.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { cleanupDeletedIssueCaches } from "./delete-cache";
|
||||
import { issueKeys } from "./queries";
|
||||
import { useRecentIssuesStore } from "./stores/recent-issues-store";
|
||||
|
||||
const WS_ID = "ws-a";
|
||||
|
||||
beforeEach(() => {
|
||||
useRecentIssuesStore.setState({ byWorkspace: {} });
|
||||
});
|
||||
|
||||
describe("cleanupDeletedIssueCaches — recent issues store", () => {
|
||||
it("removes the deleted issue from the recent issues bucket", () => {
|
||||
const { recordVisit } = useRecentIssuesStore.getState();
|
||||
recordVisit(WS_ID, "issue-1");
|
||||
recordVisit(WS_ID, "issue-2");
|
||||
|
||||
const qc = new QueryClient();
|
||||
cleanupDeletedIssueCaches(qc, WS_ID, "issue-1");
|
||||
|
||||
const ids = useRecentIssuesStore
|
||||
.getState()
|
||||
.byWorkspace[WS_ID]?.map((e) => e.id);
|
||||
expect(ids).toEqual(["issue-2"]);
|
||||
});
|
||||
|
||||
it("does not touch the recent bucket of an unrelated workspace", () => {
|
||||
const { recordVisit } = useRecentIssuesStore.getState();
|
||||
recordVisit(WS_ID, "issue-1");
|
||||
recordVisit("ws-b", "issue-1");
|
||||
|
||||
const qc = new QueryClient();
|
||||
cleanupDeletedIssueCaches(qc, WS_ID, "issue-1");
|
||||
|
||||
const state = useRecentIssuesStore.getState().byWorkspace;
|
||||
expect(state[WS_ID]).toBeUndefined();
|
||||
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-1"]);
|
||||
});
|
||||
|
||||
it("still removes the cached detail query for the deleted issue", () => {
|
||||
const qc = new QueryClient();
|
||||
qc.setQueryData(issueKeys.detail(WS_ID, "issue-1"), { id: "issue-1" });
|
||||
|
||||
cleanupDeletedIssueCaches(qc, WS_ID, "issue-1");
|
||||
|
||||
expect(qc.getQueryData(issueKeys.detail(WS_ID, "issue-1"))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { labelKeys } from "../labels/queries";
|
||||
import type { Issue, ListIssuesCache } from "../types";
|
||||
import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers";
|
||||
import { issueKeys } from "./queries";
|
||||
import { useRecentIssuesStore } from "./stores/recent-issues-store";
|
||||
|
||||
export type DeletedIssueCacheMetadata = {
|
||||
parentIssueIds: string[];
|
||||
@@ -116,6 +117,7 @@ export function invalidateDeletedIssueParentCaches(
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
|
||||
}
|
||||
|
||||
export function invalidateDeletedIssueDependentCaches(
|
||||
@@ -171,4 +173,10 @@ export function cleanupDeletedIssueCaches(
|
||||
// scheduled bar visible right now.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
||||
invalidateDeletedIssueDependentCaches(qc, wsId);
|
||||
|
||||
// Recent Issues store persists to localStorage and survives reloads, so a
|
||||
// deleted id left behind keeps the Cmd+K command bar firing 404s on every
|
||||
// open. Both the delete mutation and the WS delete event flow through here,
|
||||
// so a single call covers self-delete and cross-client delete.
|
||||
useRecentIssuesStore.getState().forgetIssue(wsId, issueId);
|
||||
}
|
||||
|
||||
@@ -305,6 +305,15 @@ export function useUpdateIssue() {
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
// Invalidate the batched-children cache only when the parent link
|
||||
// actually changed. The WS path (ws-updaters.ts) invalidates
|
||||
// unconditionally because it doesn't know what the server change
|
||||
// touched; here onMutate already patched issueKeys.children(parent)
|
||||
// optimistically, so we only need to flush when the parent relation
|
||||
// itself moved.
|
||||
if (ctx?.parentId || newParentId) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -611,13 +620,18 @@ export function useCreateComment(issueId: string) {
|
||||
export function useUpdateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
|
||||
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds: string[] }) =>
|
||||
api.updateComment(commentId, content, attachmentIds),
|
||||
onMutate: async ({ commentId, content }) => {
|
||||
onMutate: async ({ commentId, content, attachmentIds }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
const kept = new Set(attachmentIds);
|
||||
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
|
||||
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
|
||||
old?.map((e) =>
|
||||
e.id === commentId
|
||||
? { ...e, content, attachments: e.attachments?.filter((a) => kept.has(a.id)) }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
|
||||
@@ -5,8 +5,10 @@ import { setApiInstance } from "../api";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import type { Issue, ListIssuesParams, ListIssuesResponse } from "../types";
|
||||
import {
|
||||
CHILDREN_BY_PARENTS_CHUNK_SIZE,
|
||||
PROJECT_GANTT_MAX_ISSUES,
|
||||
PROJECT_GANTT_PAGE_LIMIT,
|
||||
childrenByParentsOptions,
|
||||
issueKeys,
|
||||
projectGanttIssuesOptions,
|
||||
} from "./queries";
|
||||
@@ -45,6 +47,12 @@ function installFakeApi(listIssues: (params?: ListIssuesParams) => Promise<ListI
|
||||
setApiInstance({ listIssues } as unknown as ApiClient);
|
||||
}
|
||||
|
||||
function installFakeChildrenApi(
|
||||
listChildrenByParents: (parentIds: string[]) => Promise<{ issues: Issue[] }>,
|
||||
) {
|
||||
setApiInstance({ listChildrenByParents } as unknown as ApiClient);
|
||||
}
|
||||
|
||||
describe("projectGanttIssuesOptions", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
@@ -130,3 +138,78 @@ describe("projectGanttIssuesOptions", () => {
|
||||
expect(options.queryKey).toEqual(issueKeys.projectGantt(WS_ID, PROJECT_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("childrenByParentsOptions chunking", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
qc.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("issues a single request when parentIds fit under the chunk size", async () => {
|
||||
const parentIds = Array.from({ length: 50 }, (_, i) => `p-${i}`);
|
||||
const listChildrenByParents = vi
|
||||
.fn<(ids: string[]) => Promise<{ issues: Issue[] }>>()
|
||||
.mockResolvedValue({ issues: [] });
|
||||
installFakeChildrenApi(listChildrenByParents);
|
||||
|
||||
await qc.fetchQuery(childrenByParentsOptions(WS_ID, parentIds, qc));
|
||||
|
||||
expect(listChildrenByParents).toHaveBeenCalledTimes(1);
|
||||
expect(listChildrenByParents).toHaveBeenCalledWith(parentIds);
|
||||
});
|
||||
|
||||
it("chunks parentIds into multiple requests when over the server cap", async () => {
|
||||
// 2.5 chunks worth of parents → 3 parallel requests.
|
||||
const count = CHILDREN_BY_PARENTS_CHUNK_SIZE * 2 + 17;
|
||||
const parentIds = Array.from({ length: count }, (_, i) => `p-${i}`);
|
||||
const calls: string[][] = [];
|
||||
const listChildrenByParents = vi
|
||||
.fn<(ids: string[]) => Promise<{ issues: Issue[] }>>()
|
||||
.mockImplementation(async (ids) => {
|
||||
calls.push(ids);
|
||||
return { issues: [] };
|
||||
});
|
||||
installFakeChildrenApi(listChildrenByParents);
|
||||
|
||||
await qc.fetchQuery(childrenByParentsOptions(WS_ID, parentIds, qc));
|
||||
|
||||
expect(listChildrenByParents).toHaveBeenCalledTimes(3);
|
||||
expect(calls[0]).toHaveLength(CHILDREN_BY_PARENTS_CHUNK_SIZE);
|
||||
expect(calls[1]).toHaveLength(CHILDREN_BY_PARENTS_CHUNK_SIZE);
|
||||
expect(calls[2]).toHaveLength(17);
|
||||
// Together the chunks must cover every input parent id.
|
||||
expect(calls.flat().sort()).toEqual(parentIds.slice().sort());
|
||||
});
|
||||
|
||||
it("merges children from all chunks into one grouped map", async () => {
|
||||
const parentIds = Array.from(
|
||||
{ length: CHILDREN_BY_PARENTS_CHUNK_SIZE + 1 },
|
||||
(_, i) => `p-${i}`,
|
||||
);
|
||||
// First chunk returns a child of p-0, second chunk returns a child of
|
||||
// the last parent id (which lives alone in chunk 2).
|
||||
const lastId = parentIds[parentIds.length - 1]!;
|
||||
const listChildrenByParents = vi
|
||||
.fn<(ids: string[]) => Promise<{ issues: Issue[] }>>()
|
||||
.mockImplementation(async (ids) => {
|
||||
if (ids.includes(lastId)) {
|
||||
return { issues: [{ ...makeIssue(99), parent_issue_id: lastId }] };
|
||||
}
|
||||
return { issues: [{ ...makeIssue(1), parent_issue_id: "p-0" }] };
|
||||
});
|
||||
installFakeChildrenApi(listChildrenByParents);
|
||||
|
||||
const grouped = await qc.fetchQuery(
|
||||
childrenByParentsOptions(WS_ID, parentIds, qc),
|
||||
);
|
||||
|
||||
expect(grouped.get("p-0")).toHaveLength(1);
|
||||
expect(grouped.get(lastId)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import { keepPreviousData, queryOptions, type QueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type {
|
||||
GroupedIssuesResponse,
|
||||
@@ -56,6 +56,12 @@ export const issueKeys = {
|
||||
[...issueKeys.all(wsId), "detail", id] as const,
|
||||
children: (wsId: string, id: string) =>
|
||||
[...issueKeys.all(wsId), "children", id] as const,
|
||||
/** Prefix for invalidating all batched-children queries in a workspace. */
|
||||
childrenByParentsAll: (wsId: string) =>
|
||||
[...issueKeys.all(wsId), "children-by-parents"] as const,
|
||||
/** Full key — includes sorted parent ids for cache stability. */
|
||||
childrenByParents: (wsId: string, parentIds: readonly string[]) =>
|
||||
[...issueKeys.childrenByParentsAll(wsId), parentIds] as const,
|
||||
childProgress: (wsId: string) =>
|
||||
[...issueKeys.all(wsId), "child-progress"] as const,
|
||||
/** Full-issue timeline (single TanStack Query, no cursor). */
|
||||
@@ -384,6 +390,74 @@ export function childIssuesOptions(wsId: string, id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server cap on parent_ids per `GET /api/issues/children` request — must
|
||||
* match `listChildrenByParentsLimit` in server/internal/handler/issue.go.
|
||||
* Exceeding it returns 400, so the client chunks larger requests.
|
||||
*/
|
||||
export const CHILDREN_BY_PARENTS_CHUNK_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Batched variant of {@link childIssuesOptions}: fetches children for all
|
||||
* given parents in `GET /api/issues/children?parent_ids=…` requests, chunked
|
||||
* to {@link CHILDREN_BY_PARENTS_CHUNK_SIZE} parents each. The queryFn also
|
||||
* hydrates each parent's per-parent issueKeys.children cache so other
|
||||
* surfaces (issue-detail sub-issues panel, set-parent modal) hit the primed
|
||||
* cache instead of re-fetching. Hydration happens in queryFn (not a
|
||||
* useEffect) to avoid the setQueryData → re-render → effect loop.
|
||||
*
|
||||
* Used by SwimLaneView to resolve parent lanes without an N-request fan-out.
|
||||
* parentIds must be sorted + deduplicated by the caller for a stable cache key.
|
||||
*/
|
||||
async function fetchAndHydrateChildrenByParents(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
parentIds: readonly string[],
|
||||
) {
|
||||
// Chunk to respect the server cap (parallel, since chunks are independent).
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < parentIds.length; i += CHILDREN_BY_PARENTS_CHUNK_SIZE) {
|
||||
chunks.push([...parentIds.slice(i, i + CHILDREN_BY_PARENTS_CHUNK_SIZE)]);
|
||||
}
|
||||
const responses = await Promise.all(chunks.map((c) => api.listChildrenByParents(c)));
|
||||
const grouped = new Map<string, Issue[]>();
|
||||
for (const response of responses) {
|
||||
for (const issue of response.issues) {
|
||||
if (!issue.parent_issue_id) continue;
|
||||
const bucket = grouped.get(issue.parent_issue_id);
|
||||
if (bucket) {
|
||||
bucket.push(issue);
|
||||
} else {
|
||||
grouped.set(issue.parent_issue_id, [issue]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [parentId, children] of grouped) {
|
||||
// Only hydrate if the per-parent cache is empty — don't overwrite a
|
||||
// fresher result that another query (e.g. issue-detail) may have written.
|
||||
// This relies on useUpdateIssue.onMutate writing into the per-parent
|
||||
// cache (not creating an empty one) — if that contract changes, batch
|
||||
// hydration here would silently stop seeding new lanes.
|
||||
const existing = qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId));
|
||||
if (!existing || existing.length === 0) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), children);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function childrenByParentsOptions(
|
||||
wsId: string,
|
||||
parentIds: readonly string[],
|
||||
qc: QueryClient,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.childrenByParents(wsId, parentIds),
|
||||
queryFn: () => fetchAndHydrateChildrenByParents(qc, wsId, parentIds),
|
||||
enabled: parentIds.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-fetch timeline options. The endpoint returns the full ordered set of
|
||||
* comments + activities for an issue (server caps at 2000 as a safety net).
|
||||
|
||||
@@ -35,6 +35,65 @@ describe("useRecentIssuesStore.recordVisit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRecentIssuesStore.forgetIssue", () => {
|
||||
it("removes a single id from the workspace bucket", () => {
|
||||
const { recordVisit, forgetIssue } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
recordVisit("ws-a", "issue-2");
|
||||
recordVisit("ws-a", "issue-3");
|
||||
|
||||
forgetIssue("ws-a", "issue-2");
|
||||
|
||||
const ids = useRecentIssuesStore
|
||||
.getState()
|
||||
.byWorkspace["ws-a"]?.map((e) => e.id);
|
||||
expect(ids).toEqual(["issue-3", "issue-1"]);
|
||||
});
|
||||
|
||||
it("drops the bucket entirely when the last id is removed", () => {
|
||||
const { recordVisit, forgetIssue } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
recordVisit("ws-b", "issue-2");
|
||||
|
||||
forgetIssue("ws-a", "issue-1");
|
||||
|
||||
const state = useRecentIssuesStore.getState().byWorkspace;
|
||||
expect(state["ws-a"]).toBeUndefined();
|
||||
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-2"]);
|
||||
});
|
||||
|
||||
it("does not touch other workspaces' buckets", () => {
|
||||
const { recordVisit, forgetIssue } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
recordVisit("ws-b", "issue-1");
|
||||
|
||||
forgetIssue("ws-a", "issue-1");
|
||||
|
||||
const state = useRecentIssuesStore.getState().byWorkspace;
|
||||
expect(state["ws-a"]).toBeUndefined();
|
||||
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-1"]);
|
||||
});
|
||||
|
||||
it("is a no-op when the id is not in the bucket", () => {
|
||||
const { recordVisit, forgetIssue } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
const before = useRecentIssuesStore.getState().byWorkspace;
|
||||
|
||||
forgetIssue("ws-a", "issue-missing");
|
||||
|
||||
expect(useRecentIssuesStore.getState().byWorkspace).toBe(before);
|
||||
});
|
||||
|
||||
it("is a no-op when the workspace has no bucket", () => {
|
||||
const { forgetIssue } = useRecentIssuesStore.getState();
|
||||
const before = useRecentIssuesStore.getState().byWorkspace;
|
||||
|
||||
forgetIssue("ws-missing", "issue-1");
|
||||
|
||||
expect(useRecentIssuesStore.getState().byWorkspace).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRecentIssuesStore.pruneWorkspaces", () => {
|
||||
it("drops buckets for workspaces not in the active set", () => {
|
||||
const { recordVisit, pruneWorkspaces } = useRecentIssuesStore.getState();
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface RecentIssueEntry {
|
||||
interface RecentIssuesState {
|
||||
byWorkspace: Record<string, RecentIssueEntry[]>;
|
||||
recordVisit: (wsId: string, id: string) => void;
|
||||
forgetIssue: (wsId: string, id: string) => void;
|
||||
pruneWorkspaces: (activeWsIds: string[]) => void;
|
||||
}
|
||||
|
||||
@@ -62,6 +63,20 @@ export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
|
||||
return { byWorkspace: nextByWorkspace };
|
||||
}),
|
||||
forgetIssue: (wsId, id) =>
|
||||
set((state) => {
|
||||
const bucket = state.byWorkspace[wsId];
|
||||
if (!bucket) return state;
|
||||
const nextBucket = bucket.filter((entry) => entry.id !== id);
|
||||
if (nextBucket.length === bucket.length) return state;
|
||||
if (nextBucket.length === 0) {
|
||||
const { [wsId]: _, ...rest } = state.byWorkspace;
|
||||
return { byWorkspace: rest };
|
||||
}
|
||||
return {
|
||||
byWorkspace: { ...state.byWorkspace, [wsId]: nextBucket },
|
||||
};
|
||||
}),
|
||||
pruneWorkspaces: (activeWsIds) =>
|
||||
set((state) => {
|
||||
const allow = new Set(activeWsIds);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { createContext, use } from "react";
|
||||
import { useStore, type StoreApi } from "zustand";
|
||||
import type { IssueViewState } from "./view-store";
|
||||
|
||||
@@ -21,14 +21,14 @@ export function ViewStoreProvider({
|
||||
}
|
||||
|
||||
export function useViewStore<T>(selector: (state: IssueViewState) => T): T {
|
||||
const store = useContext(ViewStoreContext);
|
||||
const store = use(ViewStoreContext);
|
||||
if (!store)
|
||||
throw new Error("useViewStore must be used within ViewStoreProvider");
|
||||
return useStore(store, selector);
|
||||
}
|
||||
|
||||
export function useViewStoreApi(): StoreApi<IssueViewState> {
|
||||
const store = useContext(ViewStoreContext);
|
||||
const store = use(ViewStoreContext);
|
||||
if (!store)
|
||||
throw new Error("useViewStoreApi must be used within ViewStoreProvider");
|
||||
return store;
|
||||
|
||||
@@ -12,9 +12,12 @@ import { defaultStorage } from "../../platform/storage";
|
||||
export type ViewMode = "board" | "list" | "gantt" | "swimlane";
|
||||
export type GanttZoom = "day" | "week" | "month";
|
||||
export type IssueGrouping = "status" | "assignee";
|
||||
export type SwimlaneGrouping = "parent" | "project" | "assignee";
|
||||
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export const SWIMLANE_GROUPINGS: SwimlaneGrouping[] = ["parent", "project", "assignee"];
|
||||
|
||||
export interface CardProperties {
|
||||
priority: boolean;
|
||||
description: boolean;
|
||||
@@ -79,8 +82,15 @@ export interface IssueViewState {
|
||||
listCollapsedStatuses: IssueStatus[];
|
||||
ganttZoom: GanttZoom;
|
||||
ganttShowCompleted: boolean;
|
||||
swimlaneOrder: string[];
|
||||
collapsedSwimlanes: string[];
|
||||
/** Active swimlane grouping dimension. */
|
||||
swimlaneGrouping: SwimlaneGrouping;
|
||||
/** Persisted lane order, keyed by grouping. Entries are raw lane ids
|
||||
* (parent issue id, project id, or `<assigneeType>:<assigneeId>`). */
|
||||
swimlaneOrders: Record<SwimlaneGrouping, string[]>;
|
||||
/** Persisted collapsed lanes, keyed by grouping. Same id space as
|
||||
* `swimlaneOrders`, plus the sentinel `"none"` for the pinned
|
||||
* no-X lane and `"__orphans__"` for the parent-grouping fallback. */
|
||||
collapsedSwimlanes: Record<SwimlaneGrouping, string[]>;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setGanttZoom: (zoom: GanttZoom) => void;
|
||||
toggleGanttShowCompleted: () => void;
|
||||
@@ -101,7 +111,10 @@ export interface IssueViewState {
|
||||
setSortDirection: (dir: SortDirection) => void;
|
||||
toggleCardProperty: (key: keyof CardProperties) => void;
|
||||
toggleListCollapsed: (status: IssueStatus) => void;
|
||||
setSwimlaneGrouping: (grouping: SwimlaneGrouping) => void;
|
||||
/** Update the lane order for the currently active swimlane grouping. */
|
||||
setSwimlaneOrder: (order: string[]) => void;
|
||||
/** Toggle a lane key in the currently active swimlane grouping. */
|
||||
toggleSwimlaneCollapsed: (key: string) => void;
|
||||
}
|
||||
|
||||
@@ -132,8 +145,9 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
listCollapsedStatuses: [],
|
||||
ganttZoom: "week",
|
||||
ganttShowCompleted: false,
|
||||
swimlaneOrder: [],
|
||||
collapsedSwimlanes: [],
|
||||
swimlaneGrouping: "assignee",
|
||||
swimlaneOrders: { parent: [], project: [], assignee: [] },
|
||||
collapsedSwimlanes: { parent: [], project: [], assignee: [] },
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
|
||||
@@ -239,13 +253,22 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
? state.listCollapsedStatuses.filter((s) => s !== status)
|
||||
: [...state.listCollapsedStatuses, status],
|
||||
})),
|
||||
setSwimlaneOrder: (order) => set({ swimlaneOrder: order }),
|
||||
toggleSwimlaneCollapsed: (key) =>
|
||||
setSwimlaneGrouping: (grouping) => set({ swimlaneGrouping: grouping }),
|
||||
setSwimlaneOrder: (order) =>
|
||||
set((state) => ({
|
||||
collapsedSwimlanes: state.collapsedSwimlanes.includes(key)
|
||||
? state.collapsedSwimlanes.filter((k) => k !== key)
|
||||
: [...state.collapsedSwimlanes, key],
|
||||
swimlaneOrders: { ...state.swimlaneOrders, [state.swimlaneGrouping]: order },
|
||||
})),
|
||||
toggleSwimlaneCollapsed: (key) =>
|
||||
set((state) => {
|
||||
const grouping = state.swimlaneGrouping;
|
||||
const current = state.collapsedSwimlanes[grouping];
|
||||
const next = current.includes(key)
|
||||
? current.filter((k) => k !== key)
|
||||
: [...current, key];
|
||||
return {
|
||||
collapsedSwimlanes: { ...state.collapsedSwimlanes, [grouping]: next },
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const viewStorePersistOptions = (name: string) => ({
|
||||
@@ -272,7 +295,8 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
ganttZoom: state.ganttZoom,
|
||||
ganttShowCompleted: state.ganttShowCompleted,
|
||||
swimlaneOrder: state.swimlaneOrder,
|
||||
swimlaneGrouping: state.swimlaneGrouping,
|
||||
swimlaneOrders: state.swimlaneOrders,
|
||||
collapsedSwimlanes: state.collapsedSwimlanes,
|
||||
}),
|
||||
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
|
||||
@@ -293,6 +317,13 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
|
||||
current: T,
|
||||
): T {
|
||||
const p = (persisted ?? {}) as Partial<T>;
|
||||
// `collapsedSwimlanes` changed shape from `string[]` to
|
||||
// `Record<SwimlaneGrouping, string[]>`. A snapshot saved in the old
|
||||
// shape would otherwise overwrite the default record with an array
|
||||
// and crash on first read — fall back to the default when the
|
||||
// persisted value isn't a plain object.
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
@@ -300,6 +331,12 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
|
||||
...current.cardProperties,
|
||||
...(p.cardProperties ?? {}),
|
||||
},
|
||||
swimlaneOrders: isRecord(p.swimlaneOrders)
|
||||
? { ...current.swimlaneOrders, ...p.swimlaneOrders }
|
||||
: current.swimlaneOrders,
|
||||
collapsedSwimlanes: isRecord(p.collapsedSwimlanes)
|
||||
? { ...current.collapsedSwimlanes, ...p.collapsedSwimlanes }
|
||||
: current.collapsedSwimlanes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ export function onIssueUpdated(
|
||||
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"./workspace/queries": "./workspace/queries.ts",
|
||||
"./workspace/mutations": "./workspace/mutations.ts",
|
||||
"./workspace/hooks": "./workspace/hooks.ts",
|
||||
"./workspace/avatar-url": "./workspace/avatar-url.ts",
|
||||
"./issues": "./issues/index.ts",
|
||||
"./issues/queries": "./issues/queries.ts",
|
||||
"./issues/mutations": "./issues/mutations.ts",
|
||||
@@ -74,6 +75,9 @@
|
||||
"./pins": "./pins/index.ts",
|
||||
"./pins/queries": "./pins/queries.ts",
|
||||
"./pins/mutations": "./pins/mutations.ts",
|
||||
"./billing": "./billing/index.ts",
|
||||
"./billing/queries": "./billing/queries.ts",
|
||||
"./billing/mutations": "./billing/mutations.ts",
|
||||
"./github": "./github/index.ts",
|
||||
"./github/queries": "./github/queries.ts",
|
||||
"./feedback": "./feedback/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { createContext, use, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Workspace } from "../types";
|
||||
import { workspaceListOptions } from "../workspace/queries";
|
||||
@@ -35,7 +35,7 @@ export function WorkspaceSlugProvider({
|
||||
|
||||
/** Current workspace slug from URL, or null outside workspace-scoped routes. */
|
||||
export function useWorkspaceSlug(): string | null {
|
||||
return useContext(WorkspaceSlugContext);
|
||||
return use(WorkspaceSlugContext);
|
||||
}
|
||||
|
||||
/** Same as useWorkspaceSlug, but throws if called outside a workspace route. */
|
||||
|
||||
@@ -53,6 +53,9 @@ export function AuthInitializer({
|
||||
configStore.getState().setAuthConfig({
|
||||
allowSignup: cfg.allow_signup,
|
||||
googleClientId: cfg.google_client_id,
|
||||
// Old servers omit this field — treat that as "creation allowed"
|
||||
// (the managed-cloud default) rather than blocking the UI.
|
||||
workspaceCreationDisabled: cfg.workspace_creation_disabled === true,
|
||||
});
|
||||
if (cfg.posthog_key) {
|
||||
initAnalytics({
|
||||
|
||||
@@ -6,5 +6,6 @@ export {
|
||||
projectResourceKeys,
|
||||
projectResourcesOptions,
|
||||
useCreateProjectResource,
|
||||
useUpdateProjectResource,
|
||||
useDeleteProjectResource,
|
||||
} from "./resource-queries";
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CreateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
ProjectResource,
|
||||
UpdateProjectResourceRequest,
|
||||
} from "../types";
|
||||
|
||||
export const projectResourceKeys = {
|
||||
@@ -46,6 +47,38 @@ export function useCreateProjectResource(wsId: string, projectId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
resourceId,
|
||||
data,
|
||||
}: {
|
||||
resourceId: string;
|
||||
data: UpdateProjectResourceRequest;
|
||||
}) => api.updateProjectResource(projectId, resourceId, data),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData<ListProjectResourcesResponse>(
|
||||
projectResourceKeys.list(wsId, projectId),
|
||||
(old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
resources: old.resources.map((r) =>
|
||||
r.id === updated.id ? updated : r,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: projectResourceKeys.list(wsId, projectId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProjectResource(wsId: string, projectId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
use,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
@@ -144,7 +144,7 @@ export function WSProvider({
|
||||
}
|
||||
|
||||
export function useWS() {
|
||||
const ctx = useContext(WSContext);
|
||||
const ctx = use(WSContext);
|
||||
if (!ctx) throw new Error("useWS must be used within WSProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ import type {
|
||||
TaskMessagePayload,
|
||||
TaskQueuedPayload,
|
||||
TaskDispatchPayload,
|
||||
TaskRunningPayload,
|
||||
TaskWaitingLocalDirectoryPayload,
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
TaskCancelledPayload,
|
||||
@@ -785,6 +787,43 @@ export function useRealtimeSync(
|
||||
);
|
||||
});
|
||||
|
||||
// task:running fires when the daemon transitions a previously-parked task
|
||||
// (waiting_local_directory) back into the run phase. The dispatch→running
|
||||
// path is collapsed in the handler above, so this handler exists mainly to
|
||||
// clear a stale `waiting_local_directory` pill — without it, the pill
|
||||
// would stay parked even after the daemon resumed work.
|
||||
const unsubTaskRunning = ws.on("task:running", (p) => {
|
||||
const payload = p as TaskRunningPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
qc.setQueryData<ChatPendingTask>(
|
||||
chatKeys.pendingTask(payload.chat_session_id),
|
||||
(old) => {
|
||||
if (!old || old.task_id !== payload.task_id) return old;
|
||||
return { ...old, status: "running" };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// task:waiting_local_directory fires when the daemon dequeues a task but
|
||||
// can't acquire the local_directory path lock — another task on this
|
||||
// daemon is in the same directory. Write the status so TaskStatusPill
|
||||
// can render the "Waiting for local directory" stage instead of pinning
|
||||
// a stale "Starting / Thinking" frame.
|
||||
const unsubTaskWaitingLocalDir = ws.on(
|
||||
"task:waiting_local_directory",
|
||||
(p) => {
|
||||
const payload = p as TaskWaitingLocalDirectoryPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
qc.setQueryData<ChatPendingTask>(
|
||||
chatKeys.pendingTask(payload.chat_session_id),
|
||||
(old) => {
|
||||
if (!old || old.task_id !== payload.task_id) return old;
|
||||
return { ...old, status: "waiting_local_directory" };
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// task:cancelled reaches us when:
|
||||
// 1. handleStop already cleared the cache locally (this is a no-op confirm)
|
||||
// 2. another tab / admin / system cancels — this is the only path that
|
||||
@@ -926,6 +965,8 @@ export function useRealtimeSync(
|
||||
unsubChatDone();
|
||||
unsubTaskQueued();
|
||||
unsubTaskDispatch();
|
||||
unsubTaskRunning();
|
||||
unsubTaskWaitingLocalDir();
|
||||
unsubTaskCancelled();
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
|
||||
@@ -70,7 +70,19 @@ export interface AgentTask {
|
||||
// autopilot-spawned. Check chat_session_id / autopilot_run_id to tell
|
||||
// which source produced it.
|
||||
issue_id: string;
|
||||
status: "queued" | "dispatched" | "running" | "completed" | "failed" | "cancelled";
|
||||
// `waiting_local_directory` is the daemon-emitted hold state for the
|
||||
// local_directory flow: a task that has been dispatched but is parked
|
||||
// because another task currently owns the same on-disk path lock.
|
||||
// Treated as an active (non-terminal) state alongside queued/dispatched/
|
||||
// running by every consumer that buckets tasks into "active vs done".
|
||||
status:
|
||||
| "queued"
|
||||
| "dispatched"
|
||||
| "waiting_local_directory"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
priority: number;
|
||||
dispatched_at: string | null;
|
||||
started_at: string | null;
|
||||
@@ -108,9 +120,24 @@ export interface AgentTask {
|
||||
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
|
||||
/**
|
||||
* Local working directory pinned for this task by the daemon. Empty until
|
||||
* the daemon reports a work_dir (typically once execution starts).
|
||||
* the daemon reports a work_dir (typically once execution starts). This is
|
||||
* the canonical absolute path the agent runs in; UI surfaces should prefer
|
||||
* `relative_work_dir` to avoid leaking the user's home directory.
|
||||
*/
|
||||
work_dir?: string;
|
||||
/**
|
||||
* Privacy-safe display form of `work_dir`, derived on the server. For
|
||||
* standard tasks the daemon's workspaces root has been stripped off
|
||||
* (`<wsUUID>/<taskShort>/workdir`); for local_directory tasks where the
|
||||
* path lives outside that layout, the server strips recognised home
|
||||
* prefixes (`/Users/<name>/`, `/home/<name>/`, `<drive>:/Users/<name>/`)
|
||||
* and otherwise falls back to the basename so neither the home directory
|
||||
* nor the username leak into the UI. Older backends omit the field —
|
||||
* render it conditionally and never render `work_dir` raw (not even in
|
||||
* a tooltip / `title` / `aria-label`, since the goal is that screen
|
||||
* shares and screenshots also stay safe).
|
||||
*/
|
||||
relative_work_dir?: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
@@ -141,6 +168,27 @@ export interface Agent {
|
||||
* alongside `has_custom_env`. Treat `undefined` as zero. MUL-2600.
|
||||
*/
|
||||
custom_env_key_count?: number;
|
||||
/**
|
||||
* MCP server configuration forwarded to the runtime CLI (Claude's
|
||||
* `--mcp-config`). The shape is opaque to the platform — whatever
|
||||
* JSON the CLI accepts, the daemon writes to disk verbatim. `null`
|
||||
* (or the field omitted on legacy backends) means no config; the
|
||||
* daemon falls back to the CLI's own default. MUL-2764.
|
||||
*
|
||||
* When the caller can't see secrets (an agent actor, or a non-owner
|
||||
* non-admin), the server replaces the value with `null` and sets
|
||||
* `mcp_config_redacted` to true so the UI can render a "configured
|
||||
* but hidden" state without exposing potentially sensitive fields.
|
||||
*/
|
||||
mcp_config?: unknown | null;
|
||||
/**
|
||||
* True when the server stripped `mcp_config` from this response
|
||||
* because the caller lacks permission to see secrets. The UI uses
|
||||
* this to distinguish "no config" (`mcp_config === null &&
|
||||
* !mcp_config_redacted`) from "config exists but you can't see it".
|
||||
* Older backends omit this field; treat `undefined` as false.
|
||||
*/
|
||||
mcp_config_redacted?: boolean;
|
||||
visibility: AgentVisibility;
|
||||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
@@ -283,6 +331,15 @@ export interface UpdateAgentRequest {
|
||||
* MUL-2600.
|
||||
*/
|
||||
custom_args?: string[];
|
||||
/**
|
||||
* MCP server configuration. Tri-state semantics (MUL-2764):
|
||||
* - field omitted → no change
|
||||
* - `null` → clear the column; the daemon falls back to the CLI's
|
||||
* built-in default at launch
|
||||
* - object → replace the stored JSON verbatim; the platform does
|
||||
* not validate the shape (MCP CLI accepts whatever it accepts)
|
||||
*/
|
||||
mcp_config?: unknown | null;
|
||||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
|
||||
@@ -41,6 +41,11 @@ export interface Autopilot {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WebhookEventFilter {
|
||||
event: string;
|
||||
actions?: string[];
|
||||
}
|
||||
|
||||
export interface AutopilotTrigger {
|
||||
id: string;
|
||||
autopilot_id: string;
|
||||
@@ -59,6 +64,9 @@ export interface AutopilotTrigger {
|
||||
// webhook_path when this is missing.
|
||||
webhook_url?: string | null;
|
||||
label: string | null;
|
||||
// event_filters is only present for webhook triggers. Null/empty means
|
||||
// "accept all events".
|
||||
event_filters?: WebhookEventFilter[] | null;
|
||||
last_fired_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -110,6 +118,8 @@ export interface CreateAutopilotTriggerRequest {
|
||||
cron_expression?: string;
|
||||
timezone?: string;
|
||||
label?: string;
|
||||
// event_filters is only meaningful for webhook triggers.
|
||||
event_filters?: WebhookEventFilter[];
|
||||
}
|
||||
|
||||
export interface UpdateAutopilotTriggerRequest {
|
||||
@@ -117,6 +127,8 @@ export interface UpdateAutopilotTriggerRequest {
|
||||
cron_expression?: string;
|
||||
timezone?: string;
|
||||
label?: string;
|
||||
// event_filters is only meaningful for webhook triggers.
|
||||
event_filters?: WebhookEventFilter[] | null;
|
||||
}
|
||||
|
||||
export interface ListAutopilotsResponse {
|
||||
|
||||
182
packages/core/types/billing.ts
Normal file
182
packages/core/types/billing.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// Mirrors the multica-cloud Billing module response shapes
|
||||
// (multica-cloud/docs/api/billing.md). These types are the contract our
|
||||
// frontend consumes via /api/cloud-billing/* — multica-api itself does
|
||||
// not own the schema, it just proxies bytes. Keep field names verbatim
|
||||
// with what the cloud sends.
|
||||
//
|
||||
// Unit convention (from the cloud doc):
|
||||
// - micro-credit (BIGINT): internal storage unit; 1 credit = 1_000_000 micro
|
||||
// - credit: user-facing display unit; 1 USD = 1000 credit
|
||||
// Always show users `*_credit` fields when present; only do math on
|
||||
// `*_micro` to avoid float drift.
|
||||
|
||||
// GET /balance
|
||||
export interface BillingBalance {
|
||||
owner_id: string;
|
||||
balance_micro: number;
|
||||
balance_credit: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// `tx_type` values per the cloud doc's enum. Exported as a union for
|
||||
// reference / display switches; the actual interface field below is
|
||||
// typed as plain `string` so a future cloud-side enum widening doesn't
|
||||
// crash the parser. Frontend should switch on these values when known
|
||||
// and fall back to a generic display otherwise.
|
||||
export type BillingTxType =
|
||||
| "topup"
|
||||
| "deduction"
|
||||
| "refund"
|
||||
| "expire"
|
||||
| "adjustment";
|
||||
|
||||
// `source` values per the cloud doc's enum. Same loosening rationale
|
||||
// as BillingTxType above.
|
||||
export type BillingTxSource =
|
||||
| "gateway"
|
||||
| "fleet"
|
||||
| "topup"
|
||||
| "refund"
|
||||
| "admin"
|
||||
| "system";
|
||||
|
||||
export interface BillingTransaction {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
idempotency_key: string;
|
||||
// tx_type / source are widened to string here even though the cloud
|
||||
// doc enumerates a fixed set; see comment on BillingTxType. UIs that
|
||||
// care should switch on the value and gracefully default.
|
||||
tx_type: string;
|
||||
source: string;
|
||||
// amount_micro is positive for credits, negative for deductions.
|
||||
amount_micro: number;
|
||||
balance_after: number;
|
||||
reference_id: string;
|
||||
description: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BillingTransactionsPage {
|
||||
items: BillingTransaction[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
// `source_type` of a credit batch. `purchase` = paid topup,
|
||||
// `bonus` = subscription / promo gift, `adjustment` = ops fix.
|
||||
// Exported as a union for documentation; the field is widened to
|
||||
// string in BillingBatch (same rationale as BillingTxType).
|
||||
export type BillingBatchSourceType = "purchase" | "bonus" | "adjustment";
|
||||
|
||||
export interface BillingBatch {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
source_tx_id: string;
|
||||
// Widened to string; see BillingBatchSourceType comment above.
|
||||
source_type: string;
|
||||
total_micro: number;
|
||||
remaining_micro: number;
|
||||
// expires_at omitted means the batch never expires. Bonus batches
|
||||
// typically carry an expiry; purchase batches typically don't.
|
||||
expires_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BillingBatchesPage {
|
||||
items: BillingBatch[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
// Topup order lifecycle. `pending` = checkout open, `paid` = Stripe
|
||||
// confirmed payment but credit not yet booked, `credited` = wallet
|
||||
// updated, `failed`/`canceled` = terminal failures. Exported as a
|
||||
// union for documentation; the field is widened to string in
|
||||
// BillingTopup / BillingCheckoutSessionStatus.
|
||||
export type BillingTopupStatus =
|
||||
| "pending"
|
||||
| "paid"
|
||||
| "credited"
|
||||
| "failed"
|
||||
| "canceled";
|
||||
|
||||
export interface BillingTopup {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
amount_cents: number;
|
||||
currency: string;
|
||||
credits: number;
|
||||
bonus_credits: number;
|
||||
// Widened to string; see BillingTopupStatus comment.
|
||||
status: string;
|
||||
tier_id: string;
|
||||
stripe_checkout_id: string;
|
||||
// Set when status reaches `credited` and the purchase batch was
|
||||
// minted; before that the field is empty.
|
||||
purchase_batch_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface BillingTopupsPage {
|
||||
items: BillingTopup[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
// GET /price-tiers — returns server-authoritative purchasable tiers.
|
||||
// Frontend should NEVER hard-code amount/credits; the upstream is the
|
||||
// source of truth so prices can be updated without a frontend ship.
|
||||
export interface BillingPriceTier {
|
||||
id: string;
|
||||
display_name: string;
|
||||
amount_cents: number;
|
||||
credits: number;
|
||||
// Both bonus fields are optional: when omitted, no bonus is granted.
|
||||
// When `bonus_expires_in` is omitted but `bonus_credits` is present,
|
||||
// the bonus credits never expire.
|
||||
bonus_credits?: number;
|
||||
// Go time.Duration string, e.g. "720h0m0s" = 30 days.
|
||||
bonus_expires_in?: string;
|
||||
}
|
||||
|
||||
// POST /checkout-sessions
|
||||
export interface CreateBillingCheckoutSessionRequest {
|
||||
tier_id: string;
|
||||
// Optional caller-provided email; only used by Stripe Checkout when
|
||||
// the owner doesn't yet have a Stripe customer record. Pass it when
|
||||
// we already know the user's email and want it pre-filled.
|
||||
customer_email?: string;
|
||||
}
|
||||
|
||||
export interface CreateBillingCheckoutSessionResponse {
|
||||
order_id: string;
|
||||
session_id: string;
|
||||
// Stripe-hosted Checkout URL. Frontend redirects the browser here.
|
||||
url: string;
|
||||
}
|
||||
|
||||
// GET /checkout-sessions/{session_id} — frontend polls this after
|
||||
// returning from Stripe with `?session_id=...` to surface the credit
|
||||
// status before the user navigates away.
|
||||
export interface BillingCheckoutSessionStatus {
|
||||
order_id: string;
|
||||
// Widened to string; see BillingTopupStatus comment.
|
||||
status: string;
|
||||
amount_cents: number;
|
||||
credits: number;
|
||||
bonus_credits: number;
|
||||
currency: string;
|
||||
tier_id: string;
|
||||
}
|
||||
|
||||
// POST /portal-sessions
|
||||
export interface CreateBillingPortalSessionResponse {
|
||||
url: string;
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export type WSEventType =
|
||||
| "agent:restored"
|
||||
| "task:queued"
|
||||
| "task:dispatch"
|
||||
| "task:running"
|
||||
| "task:waiting_local_directory"
|
||||
| "task:progress"
|
||||
| "task:completed"
|
||||
| "task:failed"
|
||||
@@ -239,6 +241,28 @@ export interface TaskDispatchPayload {
|
||||
chat_session_id?: string;
|
||||
}
|
||||
|
||||
export interface TaskRunningPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// task:waiting_local_directory fires when the daemon dequeues a task but
|
||||
// can't immediately acquire the on-disk path lock — another task on this
|
||||
// daemon is already executing in the same local_directory. The optional
|
||||
// `wait_reason` mirrors the server-side hint (path / holder task id), but
|
||||
// is not yet surfaced end-to-end; the UI today only reads the status.
|
||||
export interface TaskWaitingLocalDirectoryPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
wait_reason?: string;
|
||||
}
|
||||
|
||||
export interface TaskCompletedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
@@ -385,6 +409,8 @@ export interface WSEventPayloadMap {
|
||||
"agent:restored": AgentRestoredPayload;
|
||||
"task:queued": TaskQueuedPayload;
|
||||
"task:dispatch": TaskDispatchPayload;
|
||||
"task:running": TaskRunningPayload;
|
||||
"task:waiting_local_directory": TaskWaitingLocalDirectoryPayload;
|
||||
"task:completed": TaskCompletedPayload;
|
||||
"task:failed": TaskFailedPayload;
|
||||
"task:message": TaskMessagePayload;
|
||||
|
||||
@@ -76,8 +76,11 @@ export type {
|
||||
ListProjectsResponse,
|
||||
ProjectResource,
|
||||
ProjectResourceType,
|
||||
ProjectResourceRef,
|
||||
GithubRepoResourceRef,
|
||||
LocalDirectoryResourceRef,
|
||||
CreateProjectResourceRequest,
|
||||
UpdateProjectResourceRequest,
|
||||
ListProjectResourcesResponse,
|
||||
} from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
@@ -100,6 +103,7 @@ export type {
|
||||
AutopilotRun,
|
||||
AutopilotRunStatus,
|
||||
AutopilotRunSource,
|
||||
WebhookEventFilter,
|
||||
CreateAutopilotRequest,
|
||||
UpdateAutopilotRequest,
|
||||
CreateAutopilotTriggerRequest,
|
||||
@@ -130,3 +134,21 @@ export type {
|
||||
SquadMemberStatus,
|
||||
SquadMemberStatusListResponse,
|
||||
} from "./squad";
|
||||
export type {
|
||||
BillingBalance,
|
||||
BillingTransaction,
|
||||
BillingTransactionsPage,
|
||||
BillingTxType,
|
||||
BillingTxSource,
|
||||
BillingBatch,
|
||||
BillingBatchesPage,
|
||||
BillingBatchSourceType,
|
||||
BillingTopup,
|
||||
BillingTopupsPage,
|
||||
BillingTopupStatus,
|
||||
BillingPriceTier,
|
||||
CreateBillingCheckoutSessionRequest,
|
||||
CreateBillingCheckoutSessionResponse,
|
||||
BillingCheckoutSessionStatus,
|
||||
CreateBillingPortalSessionResponse,
|
||||
} from "./billing";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user