mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 17:47:43 +02:00
Compare commits
1 Commits
v0.2.10
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
305e54e6a0 |
49
.env.example
49
.env.example
@@ -4,23 +4,8 @@ POSTGRES_USER=multica
|
||||
POSTGRES_PASSWORD=multica
|
||||
POSTGRES_PORT=5432
|
||||
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# Optional pgxpool tuning. Defaults are 25 / 5 per pod and are usually fine.
|
||||
# You can also set pool_max_conns / pool_min_conns as query params on
|
||||
# DATABASE_URL; env vars below take precedence over URL params.
|
||||
# DATABASE_MAX_CONNS=25
|
||||
# DATABASE_MIN_CONNS=5
|
||||
|
||||
# Server
|
||||
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
|
||||
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
|
||||
# "production" by default, so 888888 is DISABLED — a public instance can't
|
||||
# be logged into with any email + 888888.
|
||||
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
|
||||
# - Docker self-host on a private network you fully control, or evaluation
|
||||
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
|
||||
# enable on a publicly reachable instance.
|
||||
# See SELF_HOSTING.md for the full login setup.
|
||||
APP_ENV=
|
||||
PORT=8080
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
@@ -37,8 +22,7 @@ MULTICA_CODEX_WORKDIR=
|
||||
MULTICA_CODEX_TIMEOUT=20m
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
|
||||
# master code 888888 works (only when APP_ENV != "production"; see above).
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
@@ -56,13 +40,6 @@ CLOUDFRONT_KEY_PAIR_ID=
|
||||
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
|
||||
CLOUDFRONT_PRIVATE_KEY=
|
||||
CLOUDFRONT_DOMAIN=
|
||||
# COOKIE_DOMAIN — optional Domain attribute on session + CloudFront cookies.
|
||||
# Leave empty for single-host deployments (localhost, LAN IP, or a single
|
||||
# hostname) — session cookies become host-only, which is what the browser
|
||||
# wants. Only set it when the frontend and backend sit on different
|
||||
# subdomains of one registered domain (e.g. ".example.com"). Do NOT set it
|
||||
# to an IP address: RFC 6265 forbids IP literals in the cookie Domain
|
||||
# attribute and browsers silently drop such cookies.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
@@ -86,27 +63,3 @@ NEXT_PUBLIC_WS_URL=
|
||||
# Remote API (optional) — set to proxy local frontend to a remote backend
|
||||
# Leave empty to use local backend (localhost:8080)
|
||||
# REMOTE_API_URL=https://multica-api.copilothub.ai
|
||||
|
||||
# ==================== Self-hosting: Control Signups (fixes #930) ====================
|
||||
# Set to "false" to completely disable new user signups (recommended for private instances)
|
||||
ALLOW_SIGNUP=true
|
||||
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
|
||||
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
|
||||
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
|
||||
NEXT_PUBLIC_ALLOW_SIGNUP=true
|
||||
|
||||
# Optional: Only allow emails from these domains (comma-separated)
|
||||
ALLOWED_EMAIL_DOMAINS=
|
||||
|
||||
# Optional: Only allow these exact email addresses (comma-separated)
|
||||
ALLOWED_EMAILS=
|
||||
|
||||
# ==================== Analytics (PostHog) ====================
|
||||
# Product analytics events feed the acquisition → activation → expansion funnel.
|
||||
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server
|
||||
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
|
||||
POSTHOG_API_KEY=
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
|
||||
ANALYTICS_DISABLED=
|
||||
|
||||
|
||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -44,60 +44,3 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
|
||||
# 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
|
||||
# can be signed + notarized with Apple Developer credentials that are
|
||||
# not (yet) wired into CI.
|
||||
desktop:
|
||||
needs: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: linux
|
||||
- os: windows-latest
|
||||
target: win
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install rpmbuild (Linux)
|
||||
if: matrix.target == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Package Desktop installers (${{ matrix.target }})
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# electron-builder's GitHub publisher reads this:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable code signing on Linux/Windows for now — the public
|
||||
# release is unsigned for these platforms, the CLI carries the
|
||||
# trust boundary. Set CSC_LINK in repo secrets to enable
|
||||
# Windows signing later.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish always
|
||||
|
||||
@@ -21,12 +21,12 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
# Legacy archive name kept so already-released CLIs (whose `multica update`
|
||||
# looks for `multica_{os}_{arch}.{ext}`) can keep self-updating. Remove
|
||||
# once those versions are no longer in use.
|
||||
- id: legacy
|
||||
- id: default
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
@@ -34,16 +34,6 @@ archives:
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
# Versioned archive name used by current CLI / install scripts /
|
||||
# desktop bootstrap going forward.
|
||||
- id: versioned
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
@@ -58,8 +48,6 @@ changelog:
|
||||
|
||||
brews:
|
||||
- name: multica
|
||||
ids:
|
||||
- versioned
|
||||
repository:
|
||||
owner: multica-ai
|
||||
name: homebrew-tap
|
||||
|
||||
@@ -76,8 +76,7 @@ fi
|
||||
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
|
||||
|
||||
# Download and extract
|
||||
VERSION="${LATEST#v}"
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica-cli-${VERSION}-${OS}-${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
tar -xzf /tmp/multica.tar.gz -C /tmp multica
|
||||
sudo mv /tmp/multica /usr/local/bin/multica
|
||||
rm /tmp/multica.tar.gz
|
||||
|
||||
3
Makefile
3
Makefile
@@ -66,8 +66,7 @@ selfhost:
|
||||
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo "Log in with any email + verification code: 888888"; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
|
||||
@@ -14,15 +14,6 @@ All configuration is done via environment variables. Copy `.env.example` as a st
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
### Database Pool Tuning (Optional)
|
||||
|
||||
These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_MAX_CONNS` | pgxpool max connections per pod. `pod_count × DATABASE_MAX_CONNS` should stay well below the Postgres `max_connections` ceiling. With a connection pooler (PgBouncer / RDS Proxy / Supavisor) in front, this can be raised significantly. | `25` |
|
||||
| `DATABASE_MIN_CONNS` | pgxpool warm baseline connections per pod. Auto-clamped to `DATABASE_MAX_CONNS`. | `5` |
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
@@ -53,14 +44,7 @@ For file uploads and attachments, configure S3 and CloudFront:
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
### Cookies
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
|
||||
|
||||
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Server
|
||||
|
||||
|
||||
@@ -21,26 +21,23 @@ mac:
|
||||
- zip
|
||||
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
|
||||
# `${name}` produces for scoped package names.
|
||||
# Naming scheme: multica-desktop-<version>-<platform>-<arch>.<ext>
|
||||
# so the filename alone surfaces kind, version, platform, and CPU arch.
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
|
||||
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
|
||||
# unaffected because `pnpm package` already requires the Developer ID
|
||||
# signing cert — notarization is a strict superset.
|
||||
notarize: true
|
||||
dmg:
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: multica-desktop-${version}-windows-${arch}.${ext}
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
publish:
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "node scripts/package.mjs",
|
||||
"package:all": "node scripts/package.mjs --all-platforms --publish never",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// skip the build and fall through to auto-install at runtime. A genuine
|
||||
// Go compile error is fatal — you want that to block dev, not hide.
|
||||
|
||||
import { access, chmod, copyFile, mkdir, rm } from "node:fs/promises";
|
||||
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -23,54 +23,8 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..", "..");
|
||||
const serverDir = join(repoRoot, "server");
|
||||
|
||||
const PLATFORM_TO_GOOS = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
|
||||
const SUPPORTED_ARCHS = new Set(["x64", "arm64"]);
|
||||
|
||||
function runtimePlatformFromArgs(argv) {
|
||||
const flagIndex = argv.indexOf("--target-platform");
|
||||
if (flagIndex === -1) return process.platform;
|
||||
return argv[flagIndex + 1] ?? "";
|
||||
}
|
||||
|
||||
function runtimeArchFromArgs(argv) {
|
||||
const flagIndex = argv.indexOf("--target-arch");
|
||||
if (flagIndex === -1) return process.arch;
|
||||
return argv[flagIndex + 1] ?? "";
|
||||
}
|
||||
|
||||
function normalizeRuntimePlatform(platform) {
|
||||
if (platform in PLATFORM_TO_GOOS) return platform;
|
||||
throw new Error(
|
||||
`[bundle-cli] unsupported target platform: ${platform}. ` +
|
||||
"Use darwin, linux, or win32.",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRuntimeArch(arch) {
|
||||
if (SUPPORTED_ARCHS.has(arch)) return arch;
|
||||
throw new Error(
|
||||
`[bundle-cli] unsupported target architecture: ${arch}. ` +
|
||||
"Use x64 or arm64.",
|
||||
);
|
||||
}
|
||||
|
||||
function binaryNameForPlatform(platform) {
|
||||
return platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
|
||||
const targetPlatform = normalizeRuntimePlatform(
|
||||
runtimePlatformFromArgs(process.argv.slice(2)),
|
||||
);
|
||||
const targetArch = normalizeRuntimeArch(runtimeArchFromArgs(process.argv.slice(2)));
|
||||
const goos = PLATFORM_TO_GOOS[targetPlatform];
|
||||
const goarch = targetArch === "x64" ? "amd64" : targetArch;
|
||||
const binName = binaryNameForPlatform(targetPlatform);
|
||||
const srcBinary = join(serverDir, "bin", `${goos}-${goarch}`, binName);
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
const srcBinary = join(serverDir, "bin", binName);
|
||||
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
|
||||
const destBinary = join(destDir, binName);
|
||||
|
||||
@@ -107,9 +61,8 @@ if (hasGo()) {
|
||||
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
|
||||
|
||||
console.log(
|
||||
`[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`,
|
||||
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
|
||||
);
|
||||
await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true });
|
||||
execFileSync(
|
||||
"go",
|
||||
[
|
||||
@@ -117,19 +70,10 @@ if (hasGo()) {
|
||||
"-ldflags",
|
||||
ldflags,
|
||||
"-o",
|
||||
srcBinary,
|
||||
join("bin", binName),
|
||||
"./cmd/multica",
|
||||
],
|
||||
{
|
||||
cwd: serverDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: "0",
|
||||
GOOS: goos,
|
||||
GOARCH: goarch,
|
||||
},
|
||||
},
|
||||
{ cwd: serverDir, stdio: "inherit" },
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -144,11 +88,9 @@ if (!(await exists(srcBinary))) {
|
||||
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
|
||||
`auto-installing the latest release at runtime.`,
|
||||
);
|
||||
await rm(destDir, { recursive: true, force: true });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await rm(destDir, { recursive: true, force: true });
|
||||
await mkdir(destDir, { recursive: true });
|
||||
await copyFile(srcBinary, destBinary);
|
||||
await chmod(destBinary, 0o755);
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
|
||||
// produces matching CLI and Desktop versions.
|
||||
//
|
||||
// Builds the Electron bundles once, then for each requested target
|
||||
// (platform + arch) compiles the matching Go CLI into resources/bin/ and
|
||||
// invokes electron-builder with `-c.extraMetadata.version=<derived>` so
|
||||
// the override applies at build time without mutating the tracked
|
||||
// package.json.
|
||||
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
|
||||
// into resources/bin/), then `electron-vite build` to produce the
|
||||
// main/preload/renderer bundles under out/, then invokes electron-builder
|
||||
// with `-c.extraMetadata.version=<derived>` so the override applies at
|
||||
// build time without mutating the tracked package.json.
|
||||
//
|
||||
// The electron-vite step is important: electron-builder only packages
|
||||
// whatever is already in out/, so skipping it (or relying on stale
|
||||
@@ -30,45 +30,6 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(here, "..");
|
||||
const bundleCliScript = resolve(here, "bundle-cli.mjs");
|
||||
|
||||
const PLATFORM_CONFIG = {
|
||||
mac: {
|
||||
aliases: new Set(["--mac", "--macos", "-m"]),
|
||||
builderFlag: "--mac",
|
||||
runtimePlatform: "darwin",
|
||||
label: "macOS",
|
||||
},
|
||||
win: {
|
||||
aliases: new Set(["--win", "--windows", "-w"]),
|
||||
builderFlag: "--win",
|
||||
runtimePlatform: "win32",
|
||||
label: "Windows",
|
||||
},
|
||||
linux: {
|
||||
aliases: new Set(["--linux", "-l"]),
|
||||
builderFlag: "--linux",
|
||||
runtimePlatform: "linux",
|
||||
label: "Linux",
|
||||
},
|
||||
};
|
||||
|
||||
const ARCH_FLAGS = new Map([
|
||||
["--x64", "x64"],
|
||||
["--arm64", "arm64"],
|
||||
["--ia32", "ia32"],
|
||||
["--armv7l", "armv7l"],
|
||||
["--universal", "universal"],
|
||||
]);
|
||||
|
||||
const SUPPORTED_CLI_ARCHS = new Set(["x64", "arm64"]);
|
||||
const MAC_ALL_PLATFORM_TARGETS = [
|
||||
{ platform: "mac", arch: "arm64" },
|
||||
{ platform: "win", arch: "x64" },
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{ platform: "linux", arch: "arm64" },
|
||||
];
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
@@ -116,196 +77,14 @@ function deriveVersion() {
|
||||
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
|
||||
}
|
||||
|
||||
function uniqueOrdered(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function hostPlatformKey(platform = process.platform) {
|
||||
if (platform === "darwin") return "mac";
|
||||
if (platform === "win32") return "win";
|
||||
if (platform === "linux") return "linux";
|
||||
throw new Error(`[package] unsupported host platform: ${platform}`);
|
||||
}
|
||||
|
||||
function hostArchKey(arch = process.arch) {
|
||||
if (SUPPORTED_CLI_ARCHS.has(arch)) return arch;
|
||||
throw new Error(
|
||||
`[package] unsupported host architecture for Desktop CLI bundling: ${arch}`,
|
||||
);
|
||||
}
|
||||
|
||||
function expandPlatformShorthand(token) {
|
||||
if (!/^-[mwl]{2,}$/.test(token)) return null;
|
||||
const expanded = [];
|
||||
for (const char of token.slice(1)) {
|
||||
if (char === "m") expanded.push("mac");
|
||||
if (char === "w") expanded.push("win");
|
||||
if (char === "l") expanded.push("linux");
|
||||
}
|
||||
return uniqueOrdered(expanded);
|
||||
}
|
||||
|
||||
function platformKeyForToken(token) {
|
||||
for (const [platform, config] of Object.entries(PLATFORM_CONFIG)) {
|
||||
if (config.aliases.has(token)) return platform;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function platformTargetsTemplate() {
|
||||
return { mac: [], win: [], linux: [] };
|
||||
}
|
||||
|
||||
export function parsePackageArgs(argv) {
|
||||
const sharedArgs = [];
|
||||
const platformTargets = platformTargetsTemplate();
|
||||
const requestedPlatforms = [];
|
||||
const requestedArchs = [];
|
||||
let allPlatforms = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--all-platforms") {
|
||||
allPlatforms = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const expandedPlatforms = expandPlatformShorthand(token);
|
||||
if (expandedPlatforms) {
|
||||
requestedPlatforms.push(...expandedPlatforms);
|
||||
continue;
|
||||
}
|
||||
|
||||
const platform = platformKeyForToken(token);
|
||||
if (platform) {
|
||||
requestedPlatforms.push(platform);
|
||||
while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
||||
platformTargets[platform].push(argv[i + 1]);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const arch = ARCH_FLAGS.get(token);
|
||||
if (arch) {
|
||||
requestedArchs.push(arch);
|
||||
continue;
|
||||
}
|
||||
|
||||
sharedArgs.push(token);
|
||||
}
|
||||
|
||||
return {
|
||||
allPlatforms,
|
||||
sharedArgs,
|
||||
platformTargets,
|
||||
requestedPlatforms: uniqueOrdered(requestedPlatforms),
|
||||
requestedArchs: uniqueOrdered(requestedArchs),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBuildMatrix(parsed, platform = process.platform, arch = process.arch) {
|
||||
if (parsed.allPlatforms) {
|
||||
if (parsed.requestedPlatforms.length > 0 || parsed.requestedArchs.length > 0) {
|
||||
throw new Error(
|
||||
"[package] --all-platforms cannot be combined with explicit platform or arch flags",
|
||||
);
|
||||
}
|
||||
if (platform !== "darwin") {
|
||||
throw new Error(
|
||||
`[package] --all-platforms is only supported on macOS hosts (current: ${platform})`,
|
||||
);
|
||||
}
|
||||
return MAC_ALL_PLATFORM_TARGETS.map((target) => ({ ...target }));
|
||||
}
|
||||
|
||||
const platforms =
|
||||
parsed.requestedPlatforms.length > 0
|
||||
? parsed.requestedPlatforms
|
||||
: [hostPlatformKey(platform)];
|
||||
const archs =
|
||||
parsed.requestedArchs.length > 0
|
||||
? parsed.requestedArchs
|
||||
: [hostArchKey(arch)];
|
||||
|
||||
const unsupported = archs.filter((value) => !SUPPORTED_CLI_ARCHS.has(value));
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(
|
||||
`[package] unsupported Desktop CLI architecture(s): ${unsupported.join(", ")}. ` +
|
||||
"Use --x64 or --arm64.",
|
||||
);
|
||||
}
|
||||
|
||||
return platforms.flatMap((targetPlatform) =>
|
||||
archs.map((targetArch) => ({
|
||||
platform: targetPlatform,
|
||||
arch: targetArch,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function formatTarget(target) {
|
||||
return `${PLATFORM_CONFIG[target.platform].label} ${target.arch}`;
|
||||
}
|
||||
|
||||
export function builderArgsForTarget(
|
||||
target,
|
||||
parsed,
|
||||
version,
|
||||
{
|
||||
disableMacNotarize = false,
|
||||
hostPlatform = process.platform,
|
||||
useScopedOutputDir = false,
|
||||
} = {},
|
||||
) {
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
if (disableMacNotarize) builderArgs.push("-c.mac.notarize=false");
|
||||
builderArgs.push(PLATFORM_CONFIG[target.platform].builderFlag);
|
||||
const requestedTargets = parsed.platformTargets[target.platform];
|
||||
if (
|
||||
target.platform === "linux" &&
|
||||
hostPlatform !== "linux" &&
|
||||
requestedTargets.length === 0
|
||||
) {
|
||||
// electron-builder only guarantees AppImage/Snap when cross-building
|
||||
// Linux from macOS/Windows. Keep `package:all` portable by defaulting
|
||||
// to AppImage unless the caller explicitly requests Linux targets.
|
||||
builderArgs.push("AppImage");
|
||||
} else {
|
||||
builderArgs.push(...requestedTargets);
|
||||
}
|
||||
builderArgs.push(`--${target.arch}`);
|
||||
builderArgs.push(...parsed.sharedArgs);
|
||||
if (useScopedOutputDir) {
|
||||
builderArgs.push(
|
||||
`-c.directories.output=dist/${target.platform}-${target.arch}`,
|
||||
);
|
||||
}
|
||||
// electron-builder's update metadata file is `latest.yml` for Windows
|
||||
// regardless of arch (only Linux gets an arch suffix automatically — see
|
||||
// app-builder-lib's getArchPrefixForUpdateFile). Without an explicit
|
||||
// channel override, building Windows x64 and arm64 in two invocations
|
||||
// makes both publish `latest.yml` to the same GitHub Release, so the
|
||||
// second upload overwrites the first and one of the two architectures
|
||||
// ends up with no auto-update metadata. Route Windows arm64 to its own
|
||||
// channel so x64 keeps `latest.yml` and arm64 ships `latest-arm64.yml`;
|
||||
// the renderer-side updater pins the matching channel per arch.
|
||||
if (target.platform === "win" && target.arch === "arm64") {
|
||||
builderArgs.push("-c.publish.channel=latest-arm64");
|
||||
}
|
||||
return builderArgs;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const parsed = parsePackageArgs(passthrough);
|
||||
const buildMatrix = resolveBuildMatrix(parsed);
|
||||
console.log(
|
||||
`[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`,
|
||||
);
|
||||
// Step 1: build + bundle the Go CLI via the existing script.
|
||||
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
// Step 1: build the Electron main/preload/renderer bundles. Without
|
||||
// Step 2: build the Electron main/preload/renderer bundles. Without
|
||||
// this step electron-builder silently packages whatever is already in
|
||||
// out/, which on a fresh checkout (or after a partial build) ships an
|
||||
// app that white-screens because the renderer bundle is missing.
|
||||
@@ -324,7 +103,7 @@ function main() {
|
||||
process.exit(viteResult.status ?? 1);
|
||||
}
|
||||
|
||||
// Step 2: derive the version that should be written into the app.
|
||||
// Step 3: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
@@ -334,58 +113,43 @@ function main() {
|
||||
);
|
||||
}
|
||||
|
||||
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
|
||||
if (disableMacNotarize) {
|
||||
// Step 4: assemble electron-builder args.
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// sets `notarize: true` so real releases notarize in-build (keeping the
|
||||
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
|
||||
// who just wants to smoke-test a local package doesn't have Apple
|
||||
// credentials, and would otherwise hit a hard failure at the notarize
|
||||
// step. Detect the missing env and flip notarize off for this run only.
|
||||
if (!process.env.APPLE_TEAM_ID) {
|
||||
console.warn(
|
||||
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
|
||||
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
|
||||
);
|
||||
builderArgs.push("-c.mac.notarize=false");
|
||||
}
|
||||
|
||||
const useScopedOutputDir = buildMatrix.length > 1;
|
||||
builderArgs.push(...passthrough);
|
||||
|
||||
// Step 3: for each requested target, build the matching CLI into
|
||||
// resources/bin/ and package that target in isolation.
|
||||
for (const target of buildMatrix) {
|
||||
console.log(`[package] bundling CLI → ${formatTarget(target)}`);
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
bundleCliScript,
|
||||
"--target-platform",
|
||||
PLATFORM_CONFIG[target.platform].runtimePlatform,
|
||||
"--target-arch",
|
||||
target.arch,
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
},
|
||||
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// for the script run, so spawnSync finds the binary without needing a
|
||||
// shell wrapper (avoids any risk of argv interpolation).
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
);
|
||||
|
||||
const builderArgs = builderArgsForTarget(target, parsed, version, {
|
||||
disableMacNotarize,
|
||||
hostPlatform: process.platform,
|
||||
useScopedOutputDir,
|
||||
});
|
||||
|
||||
// Step 4: invoke electron-builder for the current target only.
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Only run when invoked as a CLI, not when imported by a test file.
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
builderArgsForTarget,
|
||||
normalizeGitVersion,
|
||||
parsePackageArgs,
|
||||
resolveBuildMatrix,
|
||||
stripLeadingSeparator,
|
||||
} from "./package.mjs";
|
||||
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
|
||||
|
||||
describe("normalizeGitVersion", () => {
|
||||
it("returns null for empty / nullish input", () => {
|
||||
@@ -65,175 +59,3 @@ describe("stripLeadingSeparator", () => {
|
||||
expect(stripLeadingSeparator([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePackageArgs", () => {
|
||||
it("collects per-platform targets and shared args", () => {
|
||||
expect(
|
||||
parsePackageArgs([
|
||||
"--win", "nsis",
|
||||
"--mac", "dmg", "zip",
|
||||
"--arm64",
|
||||
"--publish", "never",
|
||||
]),
|
||||
).toEqual({
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: {
|
||||
mac: ["dmg", "zip"],
|
||||
win: ["nsis"],
|
||||
linux: [],
|
||||
},
|
||||
requestedPlatforms: ["win", "mac"],
|
||||
requestedArchs: ["arm64"],
|
||||
});
|
||||
});
|
||||
|
||||
it("expands combined short flags", () => {
|
||||
expect(parsePackageArgs(["-mw", "--x64"]).requestedPlatforms).toEqual([
|
||||
"mac",
|
||||
"win",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks the all-platforms shortcut", () => {
|
||||
expect(parsePackageArgs(["--all-platforms", "--publish", "never"]).allPlatforms).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBuildMatrix", () => {
|
||||
it("defaults to the current host platform and arch", () => {
|
||||
expect(
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: [],
|
||||
requestedArchs: [],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toEqual([{ platform: "mac", arch: "arm64" }]);
|
||||
});
|
||||
|
||||
it("expands all-platforms on macOS", () => {
|
||||
expect(
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: true,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: [],
|
||||
requestedArchs: [],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toEqual([
|
||||
{ platform: "mac", arch: "arm64" },
|
||||
{ platform: "win", arch: "x64" },
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{ platform: "linux", arch: "arm64" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported architectures", () => {
|
||||
expect(() =>
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["universal"],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toThrow(/unsupported Desktop CLI architecture/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("builderArgsForTarget", () => {
|
||||
it("adds scoped output directories for multi-target builds", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: { mac: [], win: ["nsis"], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["arm64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{
|
||||
disableMacNotarize: true,
|
||||
hostPlatform: "darwin",
|
||||
useScopedOutputDir: true,
|
||||
},
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"-c.mac.notarize=false",
|
||||
"--win",
|
||||
"nsis",
|
||||
"--arm64",
|
||||
"--publish",
|
||||
"never",
|
||||
"-c.directories.output=dist/win-arm64",
|
||||
"-c.publish.channel=latest-arm64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not override the publish channel for Windows x64 (default latest.yml)", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "win", arch: "x64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "always"],
|
||||
platformTargets: { mac: [], win: ["nsis"], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["x64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{ hostPlatform: "win32", useScopedOutputDir: true },
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"--win",
|
||||
"nsis",
|
||||
"--x64",
|
||||
"--publish",
|
||||
"always",
|
||||
"-c.directories.output=dist/win-x64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults linux cross-builds to AppImage on non-Linux hosts", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: ["linux"],
|
||||
requestedArchs: ["x64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{ hostPlatform: "darwin" },
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"--linux",
|
||||
"AppImage",
|
||||
"--x64",
|
||||
"--publish",
|
||||
"never",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,15 +8,35 @@ import { pipeline } from "stream/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
|
||||
|
||||
// Desktop prefers the bundled `multica` CLI shipped inside the app for
|
||||
// same-repo builds, but it can also repair or bootstrap a managed copy in
|
||||
// userData on first launch when the bundled binary is missing or unusable.
|
||||
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
|
||||
// launch, so users never have to brew-install anything. Build-time decoupled:
|
||||
// we don't bundle the binary into the .app, we download whatever the upstream
|
||||
// release is at first run.
|
||||
|
||||
const GITHUB_LATEST_BASE =
|
||||
"https://github.com/multica-ai/multica/releases/latest/download";
|
||||
|
||||
function platformAssetName(): string {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
if (!os || !arch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
const ext = process.platform === "win32" ? "zip" : "tar.gz";
|
||||
return `multica_${os}_${arch}.${ext}`;
|
||||
}
|
||||
|
||||
function binaryName(): string {
|
||||
return process.platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
@@ -72,8 +92,14 @@ async function sha256OfFile(path: string): Promise<string> {
|
||||
async function verifyChecksum(
|
||||
archivePath: string,
|
||||
assetName: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const checksums = await fetchChecksums();
|
||||
const expected = checksums.get(assetName);
|
||||
if (!expected) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const actual = await sha256OfFile(archivePath);
|
||||
if (actual.toLowerCase() !== expected) {
|
||||
throw new Error(
|
||||
@@ -92,14 +118,7 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
|
||||
|
||||
async function installFresh(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
const checksums = await fetchChecksums();
|
||||
const assetName = selectPlatformReleaseAssetName(checksums.keys());
|
||||
const expectedChecksum = checksums.get(assetName);
|
||||
if (!expectedChecksum) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const assetName = platformAssetName();
|
||||
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
|
||||
|
||||
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
|
||||
@@ -111,7 +130,7 @@ async function installFresh(): Promise<string> {
|
||||
await downloadToFile(url, archivePath);
|
||||
|
||||
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
|
||||
await verifyChecksum(archivePath, assetName, expectedChecksum);
|
||||
await verifyChecksum(archivePath, assetName);
|
||||
|
||||
console.log(`[cli-bootstrap] extracting ${assetName}`);
|
||||
await extractArchive(archivePath, workDir);
|
||||
@@ -124,7 +143,6 @@ async function installFresh(): Promise<string> {
|
||||
}
|
||||
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await rm(target, { force: true }).catch(() => {});
|
||||
await rename(extractedBin, target);
|
||||
await chmod(target, 0o755);
|
||||
|
||||
@@ -148,10 +166,8 @@ async function installFresh(): Promise<string> {
|
||||
* the managed userData location, returns it immediately. Otherwise downloads
|
||||
* the latest release asset for the current platform and installs it.
|
||||
*/
|
||||
export async function ensureManagedCli(
|
||||
options: { forceInstall?: boolean } = {},
|
||||
): Promise<string> {
|
||||
export async function ensureManagedCli(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
if (existsSync(target) && !options.forceInstall) return target;
|
||||
if (existsSync(target)) return target;
|
||||
return installFresh();
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
|
||||
|
||||
describe("selectPlatformReleaseAssetName", () => {
|
||||
it("prefers the versioned archive name when both exist", () => {
|
||||
const assetNames = [
|
||||
"checksums.txt",
|
||||
"multica_darwin_amd64.tar.gz",
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the legacy archive name when only legacy is present", () => {
|
||||
const assetNames = ["checksums.txt", "multica_darwin_amd64.tar.gz"];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica_darwin_amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the renamed darwin archive from release assets", () => {
|
||||
const assetNames = [
|
||||
"checksums.txt",
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
"multica-cli-1.2.3-darwin-arm64.tar.gz",
|
||||
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the renamed windows zip archive", () => {
|
||||
const assetNames = [
|
||||
"multica-cli-1.2.3-windows-amd64.zip",
|
||||
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "win32", "x64")).toBe(
|
||||
"multica-cli-1.2.3-windows-amd64.zip",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when the current platform asset is missing", () => {
|
||||
expect(() =>
|
||||
selectPlatformReleaseAssetName(
|
||||
["multica-cli-1.2.3-linux-amd64.tar.gz", "multica_linux_amd64.tar.gz"],
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toThrow(/no release asset found/);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
const RELEASE_ARCHIVE_PREFIX = "multica-cli-";
|
||||
|
||||
function platformArchiveDescriptor(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
): { os: string; arch: string; ext: string } {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[platform];
|
||||
const mappedArch = archMap[arch];
|
||||
if (!os || !mappedArch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${platform}/${arch}`,
|
||||
);
|
||||
}
|
||||
const ext = platform === "win32" ? "zip" : "tar.gz";
|
||||
return { os, arch: mappedArch, ext };
|
||||
}
|
||||
|
||||
export function selectPlatformReleaseAssetName(
|
||||
assetNames: Iterable<string>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
): string {
|
||||
const { os, arch: mappedArch, ext } = platformArchiveDescriptor(
|
||||
platform,
|
||||
arch,
|
||||
);
|
||||
const names = [...assetNames];
|
||||
|
||||
// Prefer the versioned `multica-cli-<v>-<os>-<arch>.<ext>` name; fall
|
||||
// back to the legacy `multica_<os>_<arch>.<ext>` so older releases that
|
||||
// only ship the legacy archive keep working.
|
||||
const suffix = `-${os}-${mappedArch}.${ext}`;
|
||||
const matches = names.filter(
|
||||
(name) =>
|
||||
name.startsWith(RELEASE_ARCHIVE_PREFIX) && name.endsWith(suffix),
|
||||
);
|
||||
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`multiple release assets matched current platform ${suffix}: ${matches.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const legacyName = `multica_${os}_${mappedArch}.${ext}`;
|
||||
if (names.includes(legacyName)) {
|
||||
return legacyName;
|
||||
}
|
||||
|
||||
throw new Error(`no release asset found for current platform: ${suffix}`);
|
||||
}
|
||||
@@ -316,36 +316,6 @@ function bundledCliPath(): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function probeCliBinary(
|
||||
bin: string,
|
||||
source: "bundled" | "managed" | "path",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
if (typeof parsed.version === "string" && parsed.version.length > 0) {
|
||||
return parsed.version;
|
||||
}
|
||||
console.warn(
|
||||
`[daemon] ignoring ${source} CLI at ${bin}: version output was missing or invalid`,
|
||||
);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(`[daemon] ignoring ${source} CLI at ${bin}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable `multica` binary path. Priority:
|
||||
* 1. Cached result from a previous successful resolve.
|
||||
@@ -369,55 +339,27 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
cliResolvePromise = (async () => {
|
||||
const bundled = bundledCliPath();
|
||||
if (existsSync(bundled)) {
|
||||
const version = await probeCliBinary(bundled, "bundled");
|
||||
if (version) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
cachedCliBinaryVersion = version;
|
||||
return bundled;
|
||||
}
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
return bundled;
|
||||
}
|
||||
|
||||
const managed = managedCliPath();
|
||||
if (existsSync(managed)) {
|
||||
const version = await probeCliBinary(managed, "managed");
|
||||
if (version) {
|
||||
cachedCliBinary = managed;
|
||||
cachedCliBinaryVersion = version;
|
||||
return managed;
|
||||
}
|
||||
cachedCliBinary = managed;
|
||||
return managed;
|
||||
}
|
||||
|
||||
try {
|
||||
const installed = await ensureManagedCli({
|
||||
forceInstall: existsSync(managed),
|
||||
});
|
||||
const version = await probeCliBinary(installed, "managed");
|
||||
if (version) {
|
||||
cachedCliBinary = installed;
|
||||
cachedCliBinaryVersion = version;
|
||||
return installed;
|
||||
}
|
||||
console.warn(
|
||||
`[daemon] managed CLI at ${installed} failed validation after install`,
|
||||
);
|
||||
const installed = await ensureManagedCli();
|
||||
cachedCliBinary = installed;
|
||||
return installed;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
|
||||
const onPath = findCliOnPath();
|
||||
cachedCliBinary = onPath;
|
||||
return onPath;
|
||||
}
|
||||
|
||||
const onPath = findCliOnPath();
|
||||
if (onPath) {
|
||||
const version = await probeCliBinary(onPath, "path");
|
||||
if (version) {
|
||||
cachedCliBinary = onPath;
|
||||
cachedCliBinaryVersion = version;
|
||||
return onPath;
|
||||
}
|
||||
}
|
||||
|
||||
cachedCliBinary = null;
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -428,10 +370,11 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the version of the currently resolved CLI binary. Cached for the
|
||||
* process lifetime — the bundled binary doesn't change after bundle time.
|
||||
* Reads the version of the currently resolved CLI binary by invoking
|
||||
* `multica version --output json`. Cached for the process lifetime — the
|
||||
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
|
||||
* Returns null on any failure (unknown `go` at bundle time, broken binary,
|
||||
* wrong-arch bundled binary, etc.) so callers can fail open.
|
||||
* etc.) so callers can fail open.
|
||||
*/
|
||||
async function getCliBinaryVersion(): Promise<string | null> {
|
||||
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
|
||||
@@ -440,7 +383,24 @@ async function getCliBinaryVersion(): Promise<string | null> {
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
}
|
||||
cachedCliBinaryVersion = await probeCliBinary(bin, "path");
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
cachedCliBinaryVersion = parsed.version ?? null;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] failed to read CLI binary version:", err);
|
||||
cachedCliBinaryVersion = null;
|
||||
}
|
||||
return cachedCliBinaryVersion;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,9 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Windows arm64 ships its own update metadata channel because
|
||||
// electron-builder's `latest.yml` is not arch-suffixed on Windows — both
|
||||
// arches would otherwise collide on the same file in the GitHub Release.
|
||||
// See scripts/package.mjs (builderArgsForTarget) for the publish-side half
|
||||
// of this pact. Pin the channel here so arm64 clients fetch
|
||||
// `latest-arm64.yml` instead of the x64 metadata.
|
||||
if (process.platform === "win32" && process.arch === "arm64") {
|
||||
autoUpdater.channel = "latest-arm64";
|
||||
}
|
||||
|
||||
const STARTUP_CHECK_DELAY_MS = 5_000;
|
||||
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
export type ManualUpdateCheckResult =
|
||||
| {
|
||||
ok: true;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
available: boolean;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
const win = getMainWindow();
|
||||
@@ -59,42 +37,10 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
const currentVersion = app.getVersion();
|
||||
// Trust electron-updater's own decision rather than re-deriving it from
|
||||
// a version-string compare. The two diverge for pre-release channels,
|
||||
// staged rollouts, downgrades, and minimum-system-version gates — in
|
||||
// those cases updateInfo.version differs from app.getVersion() but no
|
||||
// `update-available` event fires, so showing "available" here would
|
||||
// promise a download prompt that never appears.
|
||||
return {
|
||||
ok: true,
|
||||
currentVersion,
|
||||
latestVersion: result?.updateInfo.version ?? currentVersion,
|
||||
available: result?.isUpdateAvailable ?? false,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Initial check shortly after startup so we don't block boot.
|
||||
// Check for updates after a short delay to avoid blocking startup
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
|
||||
// Background poll so long-running sessions still pick up new releases
|
||||
// without requiring the user to restart the app.
|
||||
setInterval(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Periodic update check failed:", err);
|
||||
});
|
||||
}, PERIODIC_CHECK_INTERVAL_MS);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
4
apps/desktop/src/preload/index.d.ts
vendored
4
apps/desktop/src/preload/index.d.ts
vendored
@@ -53,10 +53,6 @@ interface UpdaterAPI {
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
checkForUpdates: () => Promise<
|
||||
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
|
||||
| { ok: false; error: string }
|
||||
>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -96,10 +96,6 @@ const updaterAPI = {
|
||||
},
|
||||
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("updater:install"),
|
||||
checkForUpdates: (): Promise<
|
||||
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
|
||||
| { ok: false; error: string }
|
||||
> => ipcRenderer.invoke("updater:check"),
|
||||
};
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "up-to-date"; currentVersion: string }
|
||||
| { status: "available"; latestVersion: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
const result = await window.updater.checkForUpdates();
|
||||
if (!result.ok) {
|
||||
setState({ status: "error", message: result.error });
|
||||
return;
|
||||
}
|
||||
setState(
|
||||
result.available
|
||||
? { status: "available", latestVersion: result.latestVersion }
|
||||
: { status: "up-to-date", currentVersion: result.currentVersion },
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Updates</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The desktop app checks for new versions automatically once an hour and
|
||||
shortly after launch.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Trigger a check now instead of waiting for the next automatic
|
||||
poll. Available updates appear as a notification in the corner.
|
||||
</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
You're on the latest version (v{state.currentVersion}).
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
v{state.latestVersion} is available — see the download prompt
|
||||
in the corner.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<p className="text-sm text-destructive mt-2 inline-flex items-center gap-1.5">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheck}
|
||||
disabled={state.status === "checking"}
|
||||
>
|
||||
{state.status === "checking" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
</>
|
||||
) : (
|
||||
"Check now"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,9 +19,8 @@ import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
|
||||
/**
|
||||
@@ -131,12 +130,6 @@ export const appRoutes: RouteObject[] = [
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
{
|
||||
value: "updates",
|
||||
label: "Updates",
|
||||
icon: Download,
|
||||
content: <UpdatesSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -202,14 +202,7 @@ For file uploads and attachments, configure S3 and CloudFront:
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
### Cookies
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
|
||||
|
||||
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Server
|
||||
|
||||
|
||||
@@ -11,32 +11,16 @@ function createWrapper() {
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
mockSendCode,
|
||||
mockVerifyCode,
|
||||
mockIssueCliToken,
|
||||
searchParamsState,
|
||||
authStateRef,
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
|
||||
mockSendCode: vi.fn(),
|
||||
mockVerifyCode: vi.fn(),
|
||||
mockIssueCliToken: vi.fn(),
|
||||
searchParamsState: { params: new URLSearchParams() },
|
||||
authStateRef: {
|
||||
state: {
|
||||
sendCode: vi.fn(),
|
||||
verifyCode: vi.fn(),
|
||||
user: null as null | { id: string; email: string },
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/login",
|
||||
useSearchParams: () => searchParamsState.params,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
|
||||
@@ -48,12 +32,15 @@ vi.mock("@multica/core/auth", async () => {
|
||||
await vi.importActual<typeof import("@multica/core/auth")>(
|
||||
"@multica/core/auth",
|
||||
);
|
||||
authStateRef.state.sendCode = mockSendCode;
|
||||
authStateRef.state.verifyCode = mockVerifyCode;
|
||||
const authState = {
|
||||
sendCode: mockSendCode,
|
||||
verifyCode: mockVerifyCode,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
};
|
||||
const useAuthStore = Object.assign(
|
||||
(selector: (s: typeof authStateRef.state) => unknown) =>
|
||||
selector(authStateRef.state),
|
||||
{ getState: () => authStateRef.state },
|
||||
(selector: (s: typeof authState) => unknown) => selector(authState),
|
||||
{ getState: () => authState },
|
||||
);
|
||||
return { ...actual, useAuthStore };
|
||||
});
|
||||
@@ -70,7 +57,6 @@ vi.mock("@multica/core/api", () => ({
|
||||
verifyCode: vi.fn(),
|
||||
setToken: vi.fn(),
|
||||
getMe: vi.fn(),
|
||||
issueCliToken: mockIssueCliToken,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -79,9 +65,6 @@ import LoginPage from "./page";
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
searchParamsState.params = new URLSearchParams();
|
||||
authStateRef.state.user = null;
|
||||
authStateRef.state.isLoading = false;
|
||||
});
|
||||
|
||||
it("renders login form with email input and continue button", () => {
|
||||
@@ -154,44 +137,4 @@ describe("LoginPage", () => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: MUL-1080 — if the user is already authenticated on the web
|
||||
// and the Desktop app redirects them to /login?platform=desktop, the web
|
||||
// must exchange the cookie session for a bearer token and hand it off via
|
||||
// the multica:// deep link, not silently redirect to the workspace page.
|
||||
it("mints a token and deep-links to Desktop when already logged in with platform=desktop", async () => {
|
||||
searchParamsState.params = new URLSearchParams({ platform: "desktop" });
|
||||
authStateRef.state.user = { id: "u1", email: "test@multica.ai" };
|
||||
mockIssueCliToken.mockImplementation(() =>
|
||||
Promise.resolve({ token: "handoff-jwt" }),
|
||||
);
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
|
||||
});
|
||||
|
||||
try {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockIssueCliToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
"multica://auth/callback?token=handoff-jwt",
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Open Multica Desktop" }),
|
||||
).toBeInTheDocument();
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
@@ -32,7 +22,6 @@ function LoginPageContent() {
|
||||
const cliCallbackRaw = searchParams.get("cli_callback");
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
const platform = searchParams.get("platform");
|
||||
const isDesktopHandoff = platform === "desktop" && !cliCallbackRaw;
|
||||
// `next` carries a protected URL the user was originally headed to
|
||||
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
|
||||
// "/issues" default — if `next` is absent we decide after login based on
|
||||
@@ -40,31 +29,11 @@ function LoginPageContent() {
|
||||
// cannot bounce the user off-origin after a successful login.
|
||||
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
|
||||
|
||||
const [desktopToken, setDesktopToken] = useState<string | null>(null);
|
||||
const [desktopError, setDesktopError] = useState("");
|
||||
|
||||
// Already authenticated — honor ?next= or fall back to first workspace
|
||||
// (or /workspaces/new if the user has none). Skip this entire path when
|
||||
// the user arrived to authorize the CLI.
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || cliCallbackRaw) return;
|
||||
if (isDesktopHandoff) {
|
||||
// Desktop opened the browser for login but the web session is already
|
||||
// authenticated — mint a bearer token from the cookie session and hand
|
||||
// it off via deep link instead of silently redirecting to the workspace.
|
||||
api
|
||||
.issueCliToken()
|
||||
.then(({ token }) => {
|
||||
setDesktopToken(token);
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
|
||||
})
|
||||
.catch((err) => {
|
||||
setDesktopError(
|
||||
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
@@ -74,7 +43,7 @@ function LoginPageContent() {
|
||||
router.replace(
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, qc]);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
if (nextUrl) {
|
||||
@@ -99,52 +68,6 @@ function LoginPageContent() {
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
|
||||
// While the desktop handoff is in progress (or has produced a token/error),
|
||||
// render a dedicated screen instead of flashing the login form or redirecting
|
||||
// away to a workspace page.
|
||||
if (isDesktopHandoff && user) {
|
||||
if (desktopError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Sign-in Failed</CardTitle>
|
||||
<CardDescription>{desktopError}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Opening Multica</CardTitle>
|
||||
<CardDescription>
|
||||
{desktopToken
|
||||
? "You should see a prompt to open the Multica desktop app. If nothing happens, click the button below."
|
||||
: "Preparing Desktop sign-in..."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
{desktopToken ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
|
||||
}}
|
||||
>
|
||||
Open Multica Desktop
|
||||
</Button>
|
||||
) : (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LoginPage
|
||||
onSuccess={handleSuccess}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the Next.js App Router path or query
|
||||
* string changes. Mounted once at the root so every route transition is
|
||||
* covered, including transitions into workspace-scoped subtrees.
|
||||
*
|
||||
* PostHog's own `capture_pageview: true` auto-capture is deliberately
|
||||
* disabled in `initAnalytics` so we own the event shape — this component
|
||||
* is what actually fires the event. Before this existed the acquisition
|
||||
* funnel's `/ → signup` step was empty.
|
||||
*/
|
||||
export function PageviewTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
const qs = searchParams?.toString();
|
||||
const url = qs ? `${pathname}?${qs}` : pathname;
|
||||
capturePageview(url);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import {
|
||||
setLoggedInCookie,
|
||||
clearLoggedInCookie,
|
||||
} from "@/features/auth/auth-cookie";
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
// Legacy token in localStorage → keep this session in token mode so users who
|
||||
// logged in before the cookie-auth migration stay authed. They migrate to
|
||||
@@ -44,11 +42,6 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
>
|
||||
{/* Suspense boundary is required by Next.js for useSearchParams in
|
||||
a client component mounted this high in the tree. */}
|
||||
<Suspense fallback={null}>
|
||||
<PageviewTracker />
|
||||
</Suspense>
|
||||
<WebNavigationProvider>{children}</WebNavigationProvider>
|
||||
</CoreProvider>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
|
||||
|
||||
export const en: LandingDict = {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
@@ -122,10 +120,9 @@ export const en: LandingDict = {
|
||||
headlineFaded: "in the next hour.",
|
||||
steps: [
|
||||
{
|
||||
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
|
||||
description: ALLOW_SIGNUP
|
||||
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
|
||||
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
|
||||
title: "Sign up & create your workspace",
|
||||
description:
|
||||
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
|
||||
},
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
@@ -282,39 +279,6 @@ export const en: LandingDict = {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.8",
|
||||
date: "2026-04-20",
|
||||
title: "Per-Agent Models, Kimi Runtime & Self-Host Auth",
|
||||
changes: [],
|
||||
features: [
|
||||
"Per-agent `model` field with a provider-aware dropdown — pick the LLM model for each agent from the UI or via `multica agent create/update --model`, with live discovery from each runtime's CLI",
|
||||
"Kimi CLI as a new agent runtime (Moonshot AI's `kimi-cli` over ACP), with model selection, auto-approved tool permissions, and streaming tool-call rendering",
|
||||
"Expand toggle on inline comment and reply editors for composing long text",
|
||||
],
|
||||
fixes: [
|
||||
"Posting the result comment is now an explicit, numbered step in agent workflows so final replies reach the issue instead of terminal output",
|
||||
"Agent live status card no longer leaks across issues when switching via Cmd+K",
|
||||
"Self-hosted session cookies honor the `FRONTEND_ORIGIN` scheme — plain-HTTP deployments stop silently dropping cookies, and `COOKIE_DOMAIN=<ip>` now falls back to host-only with a warning instead of breaking login",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.7",
|
||||
date: "2026-04-18",
|
||||
title: "Sub-Issues from Editor, Self-Host Gating & MCP",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create sub-issue directly from selected text in the editor bubble menu",
|
||||
"Self-hosted instance gating — `ALLOW_SIGNUP` and `ALLOWED_EMAIL_*` env vars to restrict account creation",
|
||||
"Per-agent `mcp_config` field to restore MCP access",
|
||||
"Desktop app hourly update poll with manual check button in settings",
|
||||
],
|
||||
fixes: [
|
||||
"Session hand-off to desktop when already logged in on web",
|
||||
"Open redirect vulnerability on `?next=` validated",
|
||||
"OpenClaw stops passing unsupported flags and properly delivers AgentInstructions",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.5",
|
||||
date: "2026-04-17",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
|
||||
|
||||
export const zh: LandingDict = {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
@@ -122,10 +120,9 @@ export const zh: LandingDict = {
|
||||
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
|
||||
steps: [
|
||||
{
|
||||
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
|
||||
description: ALLOW_SIGNUP
|
||||
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
|
||||
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
|
||||
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
|
||||
description:
|
||||
"\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
|
||||
@@ -282,39 +279,6 @@ export const zh: LandingDict = {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.8",
|
||||
date: "2026-04-20",
|
||||
title: "Agent 模型选择、Kimi Runtime 与自部署登录",
|
||||
changes: [],
|
||||
features: [
|
||||
"Agent 新增 `model` 字段及按 Provider 聚合的模型下拉框——可在界面或通过 `multica agent create/update --model` 为每个 Agent 选择 LLM 模型,并从各 Runtime CLI 实时发现可用模型",
|
||||
"新增 Kimi CLI Agent Runtime(Moonshot AI 的 `kimi-cli`,基于 ACP),支持模型选择、自动授权工具权限以及流式工具调用渲染",
|
||||
"评论和回复编辑器新增放大按钮,便于撰写长文本",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 工作流将“发布结果评论”提升为独立的显式步骤,确保最终回复送达 Issue 而不是只留在终端输出",
|
||||
"通过 Cmd+K 切换 Issue 时不再出现其他 Issue 的 Agent 实时状态残留",
|
||||
"自部署会话 Cookie 的 Secure 标志改由 `FRONTEND_ORIGIN` 协议决定——HTTP 部署不再因浏览器丢弃 Cookie 导致登录失败;`COOKIE_DOMAIN=<ip>` 会自动回退到 host-only 并输出警告",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.7",
|
||||
date: "2026-04-18",
|
||||
title: "编辑器创建子 Issue、自部署门禁与 MCP",
|
||||
changes: [],
|
||||
features: [
|
||||
"直接从编辑器气泡菜单将选中文本创建为子 Issue",
|
||||
"自部署实例账户门禁——`ALLOW_SIGNUP` 和 `ALLOWED_EMAIL_*` 环境变量限制注册",
|
||||
"Agent 新增 `mcp_config` 字段恢复 MCP 支持",
|
||||
"桌面应用每小时检查更新,设置中新增手动检查按钮",
|
||||
],
|
||||
fixes: [
|
||||
"网页已登录时将会话交接给桌面应用",
|
||||
"修复 `?next=` 开放重定向漏洞",
|
||||
"OpenClaw 停止传递不支持的参数,正确传递 AgentInstructions",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.5",
|
||||
date: "2026-04-17",
|
||||
|
||||
@@ -59,7 +59,6 @@ export const mockAgents: Agent[] = [
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
model: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
# Product Analytics
|
||||
|
||||
This document is the source of truth for the analytics events Multica ships
|
||||
to PostHog. Events feed the acquisition → activation → expansion funnel that
|
||||
drives our weekly Active Workspaces (WAW) north-star metric.
|
||||
|
||||
See [MUL-1122](https://github.com/multica-ai/multica) for the design context.
|
||||
|
||||
## Configuration
|
||||
|
||||
All analytics shipping is toggled by environment variables (see `.env.example`):
|
||||
|
||||
| Variable | Meaning | Default |
|
||||
|---|---|---|
|
||||
| `POSTHOG_API_KEY` | PostHog project API key. Empty = no events are shipped. | `""` |
|
||||
| `POSTHOG_HOST` | PostHog host (US or EU cloud, or self-hosted URL). | `https://us.i.posthog.com` |
|
||||
| `ANALYTICS_DISABLED` | Set to `true`/`1` to force the no-op client even when `POSTHOG_API_KEY` is set. | `""` |
|
||||
|
||||
Local dev and self-hosted instances run with `POSTHOG_API_KEY=""`, so **no
|
||||
events leave the process unless the operator explicitly opts in**.
|
||||
|
||||
### Self-hosted instances
|
||||
|
||||
Self-hosters should **never inherit a Multica-issued `POSTHOG_API_KEY`** —
|
||||
that would route their users' behavior to our analytics project. The
|
||||
defaults guarantee this:
|
||||
|
||||
- `.env.example` ships `POSTHOG_API_KEY=` empty. The Docker self-host
|
||||
compose does not set a default either.
|
||||
- With the key unset, `NewFromEnv` returns `NoopClient` and logs
|
||||
`analytics: POSTHOG_API_KEY not set, using noop client` at startup — a
|
||||
visible confirmation that nothing is shipped.
|
||||
- Operators who want their own analytics can set `POSTHOG_API_KEY` and
|
||||
`POSTHOG_HOST` to point at their own PostHog project (Cloud or
|
||||
self-hosted PostHog).
|
||||
- The frontend receives the key via `/api/config` (planned for PR 2), so
|
||||
self-hosts' blank server config also disables frontend event shipping
|
||||
automatically — no separate frontend opt-out plumbing required.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
handler → analytics.Client.Capture(Event) ← non-blocking, returns immediately
|
||||
│
|
||||
▼
|
||||
bounded queue (1024 events)
|
||||
│
|
||||
▼
|
||||
background worker: batch + POST /batch/
|
||||
│
|
||||
▼
|
||||
PostHog
|
||||
```
|
||||
|
||||
- `analytics.Capture` is **never allowed to block a request handler**. A
|
||||
broken backend must not degrade the product — when the queue is full,
|
||||
events are dropped and counted (visible via `slog` + the `dropped` counter
|
||||
on shutdown).
|
||||
- Batches flush either when `BatchSize` is reached or every `FlushEvery`
|
||||
(default 10 s), whichever comes first.
|
||||
- `Close()` drains remaining events during graceful shutdown. Called from
|
||||
`server/cmd/server/main.go` via `defer`.
|
||||
|
||||
## Identity model
|
||||
|
||||
- **`distinct_id`** — always the user's UUID for logged-in events. The
|
||||
frontend's `posthog.identify(user.id)` merges any prior anonymous events
|
||||
under the same identity, so acquisition attribution (UTM / referrer) stays
|
||||
intact across signup.
|
||||
- **`workspace_id`** — added to every event as a property when present. v1
|
||||
uses event property filtering (free tier) rather than PostHog Groups
|
||||
Analytics (paid) to compute workspace-level metrics.
|
||||
- **PII** — events carry `email_domain` (e.g. `gmail.com`), not the full
|
||||
email. Full email is stored once in person properties via `$set_once` so
|
||||
it's available for individual debugging but not broadcast with every
|
||||
event.
|
||||
|
||||
## Event contract
|
||||
|
||||
### `signup`
|
||||
|
||||
Fires when a new user is created. Covers both verification-code and Google
|
||||
OAuth entry points (`findOrCreateUser` is the single emission site).
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `email_domain` | string | Lower-cased domain portion of the user's email. |
|
||||
| `signup_source` | string | Opaque attribution bundle from the frontend cookie `multica_signup_source` (UTM + referrer). Empty when the cookie is absent. |
|
||||
| `auth_method` | string | Optional. `"google"` for Google OAuth signups. Absent for verification-code signups. |
|
||||
|
||||
Person properties set with `$set_once`:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `email` | string | Full email. Never broadcast per-event. |
|
||||
| `signup_source` | string | Same as above; kept on the person for later segmentation. |
|
||||
|
||||
### `workspace_created`
|
||||
|
||||
Fires after a `CreateWorkspace` transaction commits successfully.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `workspace_id` | string (UUID) | Added globally; present here for clarity. |
|
||||
|
||||
**Note on "first workspace" segmentation** — we deliberately do *not* stamp
|
||||
an `is_first_workspace` boolean at emit time. Computing it correctly would
|
||||
require an extra column or transaction-scoped logic that still races under
|
||||
concurrent creates. Instead, PostHog answers the same question exactly by
|
||||
looking at whether the user has a prior `workspace_created` event (use a
|
||||
funnel with "first time user does X" or a cohort on
|
||||
`person_properties.$initial_event`). No information is lost.
|
||||
|
||||
### `runtime_registered`
|
||||
|
||||
Fires the first time a `(workspace_id, daemon_id, provider)` tuple is
|
||||
upserted. Heartbeats and repeat registrations never re-emit. First-time
|
||||
detection uses Postgres `xmax = 0` on the upsert RETURNING clause — no
|
||||
extra query, no race.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `runtime_id` | string (UUID) | The newly created agent_runtime row id. |
|
||||
| `provider` | string | e.g. `"codex"`, `"claude"`. |
|
||||
| `runtime_version` | string | Version of the agent runtime binary. |
|
||||
| `cli_version` | string | Version of the `multica` CLI that registered it. |
|
||||
|
||||
`distinct_id` is the authenticated owner's user id when the daemon was
|
||||
registered via a member's JWT/PAT; daemon-token registrations fall back to
|
||||
`workspace:<workspace_id>` so PostHog doesn't bucket unrelated daemons
|
||||
under a single "anonymous" person.
|
||||
|
||||
### `issue_executed`
|
||||
|
||||
Fires **at most once per issue** — when the first task on that issue
|
||||
reaches terminal `done` state. Backed by an atomic
|
||||
`UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL RETURNING *`;
|
||||
retries, re-assignments, and comment-triggered follow-up tasks all hit the
|
||||
WHERE clause and no-op, so the `≥1 / ≥2 / ≥5 / ≥10` funnel buckets count
|
||||
distinct issues, not tasks.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `issue_id` | string (UUID) | |
|
||||
| `task_duration_ms` | int64 | Wall-clock time between `task.started_at` and `task.completed_at`. Zero when the task was created in a completed state (rare). |
|
||||
|
||||
`distinct_id` prefers the issue's human creator so agent-executed events
|
||||
flow into the issue-author's person profile (same place `signup` and
|
||||
`workspace_created` land). Agent-created issues prefix with `agent:` to
|
||||
keep PostHog from merging the agent into a user record.
|
||||
|
||||
**Note on workspace-Nth ordinals** — we deliberately do *not* stamp
|
||||
`nth_issue_for_workspace` at emit time. Computing it correctly would
|
||||
require either a serialised transaction or an advisory lock per workspace;
|
||||
two concurrent first-completions could otherwise both read `count=1` and
|
||||
emit `n=1`. PostHog answers the same question at query time via
|
||||
`row_number() OVER (PARTITION BY properties.workspace_id ORDER BY timestamp)`,
|
||||
and funnel steps of the form "workspace has had ≥2 `issue_executed`
|
||||
events" are expressible without the property. No information is lost.
|
||||
|
||||
### `team_invite_sent`
|
||||
|
||||
Fires from `CreateInvitation` after the DB row is written.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `invited_email_domain` | string | Lower-cased domain; full email lives in the invitation row, not the event. |
|
||||
| `invite_method` | string | Currently always `"email"`. Future non-email invite flows (share link, SCIM) should pass their own value. |
|
||||
|
||||
`distinct_id` is the inviter's user id.
|
||||
|
||||
### `team_invite_accepted`
|
||||
|
||||
Fires from `AcceptInvitation` after both the invitation row is marked
|
||||
accepted and the member row is inserted in the same transaction.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `days_since_invite` | int64 | Whole days from invitation creation to acceptance. Lets us segment "accepted same day" (warm) from "dug out of email weeks later" (cold). |
|
||||
|
||||
`distinct_id` is the invitee's user id — this is the event that closes the
|
||||
expansion funnel.
|
||||
|
||||
### Frontend-only events
|
||||
|
||||
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on
|
||||
every Next.js App Router path or query-string change. The tracker
|
||||
mounts once under `WebProviders` and drives the acquisition funnel's
|
||||
`/ → signup` step. posthog-js's automatic pageview capture is
|
||||
disabled in `initAnalytics` so we own the event shape.
|
||||
- Attribution is NOT a separate event; UTM + referrer origin are written
|
||||
to the `multica_signup_source` cookie on the first anonymous pageview
|
||||
and read by the backend's `signup` emission. The cookie carries a JSON
|
||||
payload URL-encoded at write time (`encodeURIComponent`) and
|
||||
URL-decoded at read time (`url.QueryUnescape`) — the JSON is never
|
||||
mid-truncated; individual values are capped at 96 chars before
|
||||
`JSON.stringify`, and the entire payload is dropped if it still exceeds
|
||||
512 chars. That way PostHog sees either intact JSON or nothing at all.
|
||||
|
||||
## Governance
|
||||
|
||||
Before adding, renaming, or removing any event:
|
||||
|
||||
1. Update this document first.
|
||||
2. Update `server/internal/analytics/events.go` constants and helpers to
|
||||
match.
|
||||
3. PR description must state which existing funnel / insight is affected.
|
||||
@@ -1,208 +0,0 @@
|
||||
// Frontend analytics glue. Thin wrapper over posthog-js.
|
||||
//
|
||||
// The source-of-truth event catalog is `docs/analytics.md`. This module only
|
||||
// handles the two things the backend can't do itself: attribution capture on
|
||||
// first anonymous pageview, and person-identity merge on login. Every funnel
|
||||
// event (signup, workspace_created, runtime_registered, issue_executed,
|
||||
// invite_sent, invite_accepted) is emitted server-side — see
|
||||
// `server/internal/analytics`.
|
||||
//
|
||||
// Configuration comes from the backend's `/api/config` response (populated
|
||||
// from POSTHOG_API_KEY on the server), NOT from NEXT_PUBLIC_* envs. That
|
||||
// keeps self-hosted Docker images from leaking our project key — their
|
||||
// backend returns an empty key and this module stays inert.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
const SIGNUP_SOURCE_COOKIE = "multica_signup_source";
|
||||
// Per-value cap keeps a long utm_content from blowing the budget. We drop
|
||||
// the entire cookie if the JSON still exceeds the overall limit — partial
|
||||
// JSON is worse than no attribution because PostHog can't parse it.
|
||||
const SIGNUP_SOURCE_VALUE_MAX_LEN = 96;
|
||||
const SIGNUP_SOURCE_MAX_LEN = 512;
|
||||
const UTM_KEYS = [
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_content",
|
||||
"utm_term",
|
||||
] as const;
|
||||
|
||||
let initialized = false;
|
||||
// auth-initializer fetches /api/config and /api/me in parallel — on a
|
||||
// slow-config path, identify() can fire before initAnalytics(). Buffer the
|
||||
// most recent pending identify (only one matters, since it's per-session)
|
||||
// and flush it inside initAnalytics.
|
||||
let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null = null;
|
||||
// Likewise pageviews: the initial "/" pageview is the anchor of the
|
||||
// acquisition funnel, and the Next.js router fires it on mount before the
|
||||
// config fetch resolves. We keep the first pending pageview so that step
|
||||
// doesn't silently drop.
|
||||
let pendingPageview: string | undefined | null = null;
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
key: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize posthog-js if a key is present. Safe to call multiple times;
|
||||
* subsequent calls with the same config are no-ops.
|
||||
*
|
||||
* Returns `true` when analytics is actually running; `false` when disabled
|
||||
* (no key, SSR, or already initialized with a conflicting key — which we
|
||||
* treat as "use the existing instance").
|
||||
*/
|
||||
export function initAnalytics(config: AnalyticsConfig | null | undefined): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (!config?.key) return false;
|
||||
if (initialized) return true;
|
||||
|
||||
posthog.init(config.key, {
|
||||
api_host: config.host || "https://us.i.posthog.com",
|
||||
// person_profiles=identified_only keeps anonymous drive-by traffic off
|
||||
// the billed events until they actually identify, which aligns with how
|
||||
// our funnel is set up: signup is the first real funnel step.
|
||||
person_profiles: "identified_only",
|
||||
// Turn off every on-by-default auto-capture surface. Our funnel is
|
||||
// narrow and explicit (the events in docs/analytics.md + a manual
|
||||
// $pageview). Autocapture floods the Activity view with anonymous
|
||||
// "clicked button" / "clicked link" noise, burns the billed event
|
||||
// budget, and risks capturing user-typed content in input values.
|
||||
// Turn things back on deliberately if we ever want them.
|
||||
capture_pageview: false,
|
||||
autocapture: false,
|
||||
capture_heatmaps: false,
|
||||
capture_dead_clicks: false,
|
||||
capture_exceptions: false,
|
||||
disable_session_recording: true,
|
||||
disable_surveys: true,
|
||||
});
|
||||
initialized = true;
|
||||
|
||||
// Flush any identify() that arrived before init resolved.
|
||||
if (pendingIdentify) {
|
||||
posthog.identify(pendingIdentify.userId, pendingIdentify.props);
|
||||
pendingIdentify = null;
|
||||
}
|
||||
// And any first pageview we captured while config was loading.
|
||||
if (pendingPageview !== null) {
|
||||
posthog.capture("$pageview", pendingPageview ? { $current_url: pendingPageview } : undefined);
|
||||
pendingPageview = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the current anonymous session into the logged-in person. Must be
|
||||
* called exactly once per auth transition (login / session-resume). Pulling
|
||||
* attribution properties into person_properties on identify is how we keep
|
||||
* UTM / referrer on the user profile without re-emitting them per event.
|
||||
*
|
||||
* Calls before initAnalytics() are buffered — auth-initializer fetches
|
||||
* config and user in parallel, so identify can arrive first.
|
||||
*/
|
||||
export function identify(userId: string, userProperties?: Record<string, unknown>): void {
|
||||
if (!initialized) {
|
||||
pendingIdentify = { userId, props: userProperties };
|
||||
return;
|
||||
}
|
||||
posthog.identify(userId, userProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the client-side identity on logout so the next login merges cleanly
|
||||
* and doesn't bleed the previous user's events into a new session.
|
||||
*/
|
||||
export function resetAnalytics(): void {
|
||||
pendingIdentify = null;
|
||||
pendingPageview = null;
|
||||
if (!initialized) return;
|
||||
posthog.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a page view. Call once per client-side navigation. We disable
|
||||
* posthog's automatic pageview tracking in init() so this module owns the
|
||||
* event shape — that makes it trivial to add properties (e.g. workspace
|
||||
* slug) without fighting the SDK.
|
||||
*
|
||||
* Calls before initAnalytics() buffer the most-recent path so the first
|
||||
* pageview isn't dropped on slow /api/config fetches. Subsequent pre-init
|
||||
* pageviews overwrite the buffer; after init flushes, every navigation
|
||||
* captures synchronously as expected.
|
||||
*/
|
||||
export function capturePageview(path?: string): void {
|
||||
if (!initialized) {
|
||||
pendingPageview = path ?? "";
|
||||
return;
|
||||
}
|
||||
posthog.capture("$pageview", path ? { $current_url: path } : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* On the very first anonymous pageview in a browser session, read UTM +
|
||||
* referrer and stash them in a cookie that the backend reads during signup.
|
||||
*
|
||||
* Never use raw `document.referrer` as attribution — it can leak OAuth
|
||||
* callback URLs with `code` / `state` in the query string. We keep only the
|
||||
* referrer's origin (scheme + host), which is what a funnel actually needs.
|
||||
*
|
||||
* This cookie is what `signup_source` in the backend's signup event reads
|
||||
* from; both fields are intentionally opaque JSON so the schema can evolve
|
||||
* without a backend deploy.
|
||||
*/
|
||||
export function captureSignupSource(): void {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return;
|
||||
if (readCookie(SIGNUP_SOURCE_COOKIE)) return;
|
||||
|
||||
const source: Record<string, string> = {};
|
||||
const cap = (v: string) =>
|
||||
v.length > SIGNUP_SOURCE_VALUE_MAX_LEN ? v.slice(0, SIGNUP_SOURCE_VALUE_MAX_LEN) : v;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const key of UTM_KEYS) {
|
||||
const v = params.get(key);
|
||||
if (v) source[key] = cap(v);
|
||||
}
|
||||
} catch {
|
||||
// URL APIs unavailable — skip silently.
|
||||
}
|
||||
|
||||
const refOrigin = safeReferrerOrigin(document.referrer);
|
||||
if (refOrigin) source.referrer_origin = cap(refOrigin);
|
||||
|
||||
if (Object.keys(source).length === 0) return;
|
||||
|
||||
const payload = JSON.stringify(source);
|
||||
// Drop rather than mid-JSON truncate — a half-string would fail to parse
|
||||
// on the backend and the attribution would be worse than missing.
|
||||
if (payload.length > SIGNUP_SOURCE_MAX_LEN) return;
|
||||
|
||||
// 30-day expiry covers the typical signup consideration window. Lax is
|
||||
// the right default — the cookie is only consumed by same-origin auth.
|
||||
const maxAge = 60 * 60 * 24 * 30;
|
||||
document.cookie = `${SIGNUP_SOURCE_COOKIE}=${encodeURIComponent(payload)}; path=/; max-age=${maxAge}; samesite=lax`;
|
||||
}
|
||||
|
||||
function safeReferrerOrigin(referrer: string): string {
|
||||
if (!referrer) return "";
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
if (url.origin === window.location.origin) return "";
|
||||
return url.origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const prefix = `${name}=`;
|
||||
const parts = document.cookie ? document.cookie.split("; ") : [];
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import type {
|
||||
RuntimeHourlyActivity,
|
||||
RuntimePing,
|
||||
RuntimeUpdate,
|
||||
RuntimeModelListRequest,
|
||||
TimelineEntry,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
@@ -471,17 +470,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
|
||||
}
|
||||
|
||||
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" });
|
||||
}
|
||||
|
||||
async getListModelsResult(
|
||||
runtimeId: string,
|
||||
requestId: string,
|
||||
): Promise<RuntimeModelListRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`);
|
||||
}
|
||||
|
||||
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
@@ -542,11 +530,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// App Config
|
||||
async getConfig(): Promise<{
|
||||
cdn_domain: string;
|
||||
posthog_key?: string;
|
||||
posthog_host?: string;
|
||||
}> {
|
||||
async getConfig(): Promise<{ cdn_domain: string }> {
|
||||
return this.fetch("/api/config");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { User, StorageAdapter } from "../types";
|
||||
import { identify as identifyAnalytics, resetAnalytics } from "../analytics";
|
||||
import { ApiError, type ApiClient } from "../api/client";
|
||||
import { setCurrentWorkspace } from "../platform/workspace-storage";
|
||||
|
||||
@@ -85,7 +84,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
api.setToken(token);
|
||||
}
|
||||
onLogin?.();
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
@@ -97,7 +95,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
api.setToken(token);
|
||||
}
|
||||
onLogin?.();
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
@@ -107,7 +104,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
api.setToken(token);
|
||||
const user = await api.getMe();
|
||||
onLogin?.();
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
set({ user, isLoading: false });
|
||||
return user;
|
||||
},
|
||||
@@ -120,7 +116,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
storage.removeItem("multica_token");
|
||||
api.setToken(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
resetAnalytics();
|
||||
onLogout?.();
|
||||
set({ user: null });
|
||||
},
|
||||
|
||||
59
packages/core/issues/stores/comment-draft-store.ts
Normal file
59
packages/core/issues/stores/comment-draft-store.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
/**
|
||||
* Persists unsent comment/reply draft text so it survives unmount —
|
||||
* e.g. switching issues, collapsing/expanding a comment card, or toggling
|
||||
* between the main comment box and a reply box.
|
||||
*
|
||||
* Keys are caller-supplied strings:
|
||||
* - `comment:<issueId>` for the main comment input on an issue
|
||||
* - `reply:<issueId>:<commentId>` for the reply input on a specific comment
|
||||
*/
|
||||
interface CommentDraftStore {
|
||||
drafts: Record<string, string>;
|
||||
getDraft: (key: string) => string;
|
||||
setDraft: (key: string, content: string) => void;
|
||||
clearDraft: (key: string) => void;
|
||||
}
|
||||
|
||||
export const useCommentDraftStore = create<CommentDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
getDraft: (key) => get().drafts[key] ?? "",
|
||||
setDraft: (key, content) =>
|
||||
set((s) => {
|
||||
if (!content) {
|
||||
if (!(key in s.drafts)) return s;
|
||||
const { [key]: _, ...rest } = s.drafts;
|
||||
return { drafts: rest };
|
||||
}
|
||||
if (s.drafts[key] === content) return s;
|
||||
return { drafts: { ...s.drafts, [key]: content } };
|
||||
}),
|
||||
clearDraft: (key) =>
|
||||
set((s) => {
|
||||
if (!(key in s.drafts)) return s;
|
||||
const { [key]: _, ...rest } = s.drafts;
|
||||
return { drafts: rest };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_comment_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useCommentDraftStore.persist.rehydrate());
|
||||
|
||||
export function commentDraftKey(issueId: string): string {
|
||||
return `comment:${issueId}`;
|
||||
}
|
||||
|
||||
export function replyDraftKey(issueId: string, commentId: string): string {
|
||||
return `reply:${issueId}:${commentId}`;
|
||||
}
|
||||
@@ -8,6 +8,11 @@ export {
|
||||
} from "./view-store-context";
|
||||
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
|
||||
export { useCommentCollapseStore } from "./comment-collapse-store";
|
||||
export {
|
||||
useCommentDraftStore,
|
||||
commentDraftKey,
|
||||
replyDraftKey,
|
||||
} from "./comment-draft-store";
|
||||
export {
|
||||
myIssuesViewStore,
|
||||
type MyIssuesViewState,
|
||||
|
||||
@@ -63,13 +63,11 @@
|
||||
"./logger": "./logger.ts",
|
||||
"./utils": "./utils.ts",
|
||||
"./constants/*": "./constants/*.ts",
|
||||
"./platform": "./platform/index.ts",
|
||||
"./analytics": "./analytics/index.ts"
|
||||
"./platform": "./platform/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"posthog-js": "catalog:",
|
||||
"zustand": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -28,9 +28,6 @@ export const RESERVED_SLUGS = new Set([
|
||||
// Platform / marketing routes (current + likely-future)
|
||||
"api",
|
||||
"admin",
|
||||
"multica", // brand name — prevent impersonation workspaces
|
||||
"www", // hostname confusable; never a legitimate workspace slug
|
||||
"new", // ambiguous verb-as-slug; reserved for future global create routes
|
||||
"help",
|
||||
"about",
|
||||
"pricing",
|
||||
|
||||
@@ -4,19 +4,12 @@ import { useEffect, type ReactNode } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getApi } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import {
|
||||
captureSignupSource,
|
||||
identify as identifyAnalytics,
|
||||
initAnalytics,
|
||||
resetAnalytics,
|
||||
} from "../analytics";
|
||||
import { configStore } from "../config";
|
||||
import { workspaceKeys } from "../workspace/queries";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import { setCurrentWorkspace } from "./workspace-storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
import type { User } from "../types";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
@@ -38,34 +31,10 @@ export function AuthInitializer({
|
||||
useEffect(() => {
|
||||
const api = getApi();
|
||||
|
||||
// Stamp attribution before anything else — the signup event (server-side)
|
||||
// reads this cookie, so it has to be present before the user hits submit.
|
||||
captureSignupSource();
|
||||
|
||||
// Fetch app config (CDN domain, PostHog key, …) in the background — non-blocking.
|
||||
api
|
||||
.getConfig()
|
||||
.then((cfg) => {
|
||||
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
|
||||
if (cfg.posthog_key) {
|
||||
initAnalytics({ key: cfg.posthog_key, host: cfg.posthog_host || "" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* config is optional — legacy file card matching degrades gracefully */
|
||||
});
|
||||
|
||||
const onAuthSuccess = (user: User) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
};
|
||||
|
||||
const onAuthFailure = () => {
|
||||
onLogout?.();
|
||||
resetAnalytics();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
};
|
||||
// Fetch app config (CDN domain, etc.) in the background — non-blocking.
|
||||
api.getConfig().then((cfg) => {
|
||||
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
|
||||
}).catch(() => { /* config is optional — legacy file card matching degrades gracefully */ });
|
||||
|
||||
if (cookieAuth) {
|
||||
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
|
||||
@@ -77,12 +46,14 @@ export function AuthInitializer({
|
||||
// selection here.
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onAuthSuccess(user);
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("cookie auth init failed", err);
|
||||
onAuthFailure();
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -99,7 +70,8 @@ export function AuthInitializer({
|
||||
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onAuthSuccess(user);
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
// Seed React Query cache so the URL-driven layout can resolve the
|
||||
// slug without a second fetch.
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
@@ -109,7 +81,8 @@ export function AuthInitializer({
|
||||
api.setToken(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
storage.removeItem("multica_token");
|
||||
onAuthFailure();
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
export * from "./hooks";
|
||||
export * from "./models";
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { RuntimeModelsResult } from "../types/agent";
|
||||
|
||||
export const runtimeModelsKeys = {
|
||||
all: () => ["runtimes", "models"] as const,
|
||||
forRuntime: (runtimeId: string) =>
|
||||
[...runtimeModelsKeys.all(), runtimeId] as const,
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const POLL_TIMEOUT_MS = 30_000;
|
||||
|
||||
// resolveRuntimeModels initiates a list-models request against the daemon
|
||||
// (via heartbeat piggyback) and polls until the daemon reports back or
|
||||
// the request times out. Returns both the models list and a
|
||||
// `supported` flag: `supported=false` means the provider ignores
|
||||
// per-agent model selection entirely (hermes today) — the UI uses
|
||||
// this to disable its dropdown instead of accepting a value that
|
||||
// wouldn't be honoured at runtime.
|
||||
export async function resolveRuntimeModels(
|
||||
runtimeId: string,
|
||||
): Promise<RuntimeModelsResult> {
|
||||
const initial = await api.initiateListModels(runtimeId);
|
||||
const start = Date.now();
|
||||
let current = initial;
|
||||
while (current.status === "pending" || current.status === "running") {
|
||||
if (Date.now() - start > POLL_TIMEOUT_MS) {
|
||||
throw new Error("model discovery timed out");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
current = await api.getListModelsResult(runtimeId, initial.id);
|
||||
}
|
||||
if (current.status === "failed" || current.status === "timeout") {
|
||||
throw new Error(current.error || "model discovery failed");
|
||||
}
|
||||
return { models: current.models ?? [], supported: current.supported };
|
||||
}
|
||||
|
||||
export function runtimeModelsOptions(runtimeId: string | null | undefined) {
|
||||
return queryOptions({
|
||||
queryKey: runtimeId
|
||||
? runtimeModelsKeys.forRuntime(runtimeId)
|
||||
: runtimeModelsKeys.all(),
|
||||
queryFn: () => resolveRuntimeModels(runtimeId as string),
|
||||
enabled: Boolean(runtimeId),
|
||||
// Models rarely change; cache for 60s to match the server-side
|
||||
// cache in agent.ListModels.
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -54,7 +54,6 @@ export interface Agent {
|
||||
visibility: AgentVisibility;
|
||||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
model: string;
|
||||
owner_id: string | null;
|
||||
skills: Skill[];
|
||||
created_at: string;
|
||||
@@ -74,7 +73,6 @@ export interface CreateAgentRequest {
|
||||
custom_args?: string[];
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
@@ -89,7 +87,6 @@ export interface UpdateAgentRequest {
|
||||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
// Skills
|
||||
@@ -190,36 +187,3 @@ export interface RuntimeUpdate {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RuntimeModel {
|
||||
id: string;
|
||||
label: string;
|
||||
provider?: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export type RuntimeModelListStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "timeout";
|
||||
|
||||
export interface RuntimeModelListRequest {
|
||||
id: string;
|
||||
runtime_id: string;
|
||||
status: RuntimeModelListStatus;
|
||||
models?: RuntimeModel[];
|
||||
supported: boolean;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Result shape returned by resolveRuntimeModels — includes the
|
||||
// "supported" bit so the UI can distinguish "no models discovered"
|
||||
// from "provider does not honour per-agent model selection".
|
||||
export interface RuntimeModelsResult {
|
||||
models: RuntimeModel[];
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,6 @@ export type {
|
||||
RuntimePingStatus,
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
RuntimeModel,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeModelListStatus,
|
||||
RuntimeModelsResult,
|
||||
IssueUsageSummary,
|
||||
} from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { Cloud, ChevronDown, Globe, Lock, Loader2 } from "lucide-react";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { ModelDropdown } from "./model-dropdown";
|
||||
import type {
|
||||
AgentVisibility,
|
||||
RuntimeDevice,
|
||||
@@ -49,7 +48,6 @@ export function CreateAgentDialog({
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [visibility, setVisibility] = useState<AgentVisibility>("private");
|
||||
const [model, setModel] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
|
||||
@@ -91,7 +89,6 @@ export function CreateAgentDialog({
|
||||
description: description.trim(),
|
||||
runtime_id: selectedRuntime.id,
|
||||
visibility,
|
||||
model: model.trim() || undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -278,14 +275,6 @@ export function CreateAgentDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<ModelDropdown
|
||||
runtimeId={selectedRuntime?.id ?? null}
|
||||
runtimeOnline={selectedRuntime?.status === "online"}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={!selectedRuntime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Cpu, Loader2, Plus, Check, Info } from "lucide-react";
|
||||
import { runtimeModelsOptions } from "@multica/core/runtimes";
|
||||
import type { RuntimeModel } from "@multica/core/types";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
|
||||
// ModelDropdown renders a searchable, creatable model picker for an agent.
|
||||
// It fetches the supported-model catalog from the selected runtime — the
|
||||
// daemon enumerates models on demand via heartbeat piggyback. Providers
|
||||
// that don't honour per-agent model selection at runtime (currently
|
||||
// hermes) return supported=false, and the dropdown renders disabled
|
||||
// with an explanation instead of silently accepting a value the
|
||||
// backend would ignore.
|
||||
export function ModelDropdown({
|
||||
runtimeId,
|
||||
runtimeOnline,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
runtimeId: string | null;
|
||||
runtimeOnline: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const modelsQuery = useQuery(
|
||||
runtimeModelsOptions(runtimeOnline ? runtimeId : null),
|
||||
);
|
||||
|
||||
const supported = modelsQuery.data?.supported ?? true;
|
||||
const models = modelsQuery.data?.models ?? [];
|
||||
const defaultModel = useMemo(() => models.find((m) => m.default), [models]);
|
||||
const grouped = useMemo(() => groupByProvider(models), [models]);
|
||||
|
||||
// When the selected runtime reports it doesn't support per-agent
|
||||
// model selection, clear any previously-saved value so we don't
|
||||
// persist a ghost configuration that never takes effect.
|
||||
useEffect(() => {
|
||||
if (!supported && value !== "") {
|
||||
onChange("");
|
||||
}
|
||||
}, [supported, value, onChange]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return grouped;
|
||||
const needle = search.toLowerCase();
|
||||
const out: Record<string, RuntimeModel[]> = {};
|
||||
for (const [provider, list] of Object.entries(grouped)) {
|
||||
const matches = list.filter(
|
||||
(m) =>
|
||||
m.id.toLowerCase().includes(needle) ||
|
||||
m.label.toLowerCase().includes(needle),
|
||||
);
|
||||
if (matches.length > 0) out[provider] = matches;
|
||||
}
|
||||
return out;
|
||||
}, [grouped, search]);
|
||||
|
||||
const trimmedSearch = search.trim();
|
||||
const exactMatch = models.some(
|
||||
(m) => m.id === trimmedSearch || m.label === trimmedSearch,
|
||||
);
|
||||
const canCreate = trimmedSearch.length > 0 && !exactMatch;
|
||||
|
||||
const select = (id: string) => {
|
||||
onChange(id);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const triggerLabel =
|
||||
value ||
|
||||
(disabled
|
||||
? "Select a runtime first"
|
||||
: runtimeOnline
|
||||
? defaultModel
|
||||
? `Default — ${defaultModel.label}`
|
||||
: "Default (provider)"
|
||||
: "Runtime offline — enter manually");
|
||||
|
||||
if (!supported && !modelsQuery.isLoading) {
|
||||
// Provider doesn't honour per-agent model selection — show a
|
||||
// clearly-disabled state so the user knows why the control is
|
||||
// inert. (Hermes reads its model from ~/.hermes/.env.)
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<div className="mt-1.5 flex items-start gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div>Model selection is managed by this runtime.</div>
|
||||
<div className="mt-0.5 text-xs">
|
||||
Configure the model on the runtime host (e.g. Hermes reads it
|
||||
from its own config file).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
{modelsQuery.isError && (
|
||||
<span className="text-xs text-muted-foreground">discovery failed</span>
|
||||
)}
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={disabled}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<Cpu className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">
|
||||
{triggerLabel}
|
||||
</div>
|
||||
{value && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{modelLabel(models, value)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--anchor-width)] p-0 overflow-hidden"
|
||||
>
|
||||
<div className="border-b border-border p-2">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search or type a model ID"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto p-1">
|
||||
{modelsQuery.isLoading && (
|
||||
<div className="flex items-center gap-2 px-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Discovering models…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!modelsQuery.isLoading &&
|
||||
Object.entries(filtered).map(([provider, list]) => (
|
||||
<div key={provider} className="mb-1">
|
||||
{provider && (
|
||||
<div className="px-2 pt-1.5 pb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{provider}
|
||||
</div>
|
||||
)}
|
||||
{list.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => select(m.id)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
m.id === value ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{m.label}</span>
|
||||
{m.default && (
|
||||
<span className="shrink-0 rounded bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary">
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.label !== m.id && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{m.id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{m.id === value && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!modelsQuery.isLoading &&
|
||||
Object.keys(filtered).length === 0 &&
|
||||
!canCreate && (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No models available.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canCreate && (
|
||||
<button
|
||||
onClick={() => select(trimmedSearch)}
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-primary transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
Use “{trimmedSearch}”
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => select("")}
|
||||
className="mt-1 flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent/50"
|
||||
>
|
||||
Clear selection (use provider default)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByProvider(models: RuntimeModel[]): Record<string, RuntimeModel[]> {
|
||||
const out: Record<string, RuntimeModel[]> = {};
|
||||
for (const m of models) {
|
||||
const key = m.provider ?? "";
|
||||
if (!out[key]) out[key] = [];
|
||||
out[key].push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function modelLabel(models: RuntimeModel[], id: string): string {
|
||||
const found = models.find((m) => m.id === id);
|
||||
if (!found) return "custom";
|
||||
return found.provider ? found.provider : "model";
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import { api } from "@multica/core/api";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
import { ProviderLogo } from "../../../runtimes/components/provider-logo";
|
||||
import { ModelDropdown } from "../model-dropdown";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
|
||||
@@ -45,7 +44,6 @@ export function SettingsTab({
|
||||
const [visibility, setVisibility] = useState<AgentVisibility>(agent.visibility);
|
||||
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(agent.runtime_id);
|
||||
const [model, setModel] = useState(agent.model ?? "");
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -92,8 +90,7 @@ export function SettingsTab({
|
||||
description !== (agent.description ?? "") ||
|
||||
visibility !== agent.visibility ||
|
||||
maxTasks !== agent.max_concurrent_tasks ||
|
||||
selectedRuntimeId !== agent.runtime_id ||
|
||||
model !== (agent.model ?? "");
|
||||
selectedRuntimeId !== agent.runtime_id;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
@@ -109,7 +106,6 @@ export function SettingsTab({
|
||||
visibility,
|
||||
max_concurrent_tasks: maxTasks,
|
||||
runtime_id: selectedRuntimeId,
|
||||
model,
|
||||
});
|
||||
toast.success("Settings saved");
|
||||
} catch {
|
||||
@@ -325,14 +321,6 @@ export function SettingsTab({
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<ModelDropdown
|
||||
runtimeId={selectedRuntime?.id ?? null}
|
||||
runtimeOnline={selectedRuntime?.status === "online"}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={!selectedRuntime}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Save className="h-3.5 w-3.5 mr-1.5" />}
|
||||
Save Changes
|
||||
|
||||
@@ -55,7 +55,6 @@ const agent: Agent = {
|
||||
visibility: "workspace",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
created_at: "2026-04-16T00:00:00Z",
|
||||
|
||||
@@ -15,7 +15,7 @@ export type TriggerFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "cus
|
||||
export interface TriggerConfig {
|
||||
frequency: TriggerFrequency;
|
||||
time: string; // HH:MM
|
||||
daysOfWeek: number[]; // 0=Sun … 6=Sat — used when frequency === "weekly"
|
||||
dayOfWeek: number; // 0=Sun … 6=Sat
|
||||
cronExpression: string; // only used when frequency === "custom"
|
||||
timezone: string; // IANA
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const FREQUENCIES: { value: TriggerFrequency; label: string }[] = [
|
||||
{ value: "hourly", label: "Hourly" },
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekdays", label: "Weekdays" },
|
||||
{ value: "weekly", label: "Days" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
@@ -102,22 +102,12 @@ export function getDefaultTriggerConfig(): TriggerConfig {
|
||||
return {
|
||||
frequency: "daily",
|
||||
time: "09:00",
|
||||
daysOfWeek: [1],
|
||||
dayOfWeek: 1,
|
||||
cronExpression: "0 9 * * 1-5",
|
||||
timezone: getLocalTimezone(),
|
||||
};
|
||||
}
|
||||
|
||||
function sortedDays(days: number[]): number[] {
|
||||
return [...new Set(days)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function formatDayList(days: number[]): string {
|
||||
const sorted = sortedDays(days);
|
||||
if (sorted.length === 0) return "—";
|
||||
return sorted.map((d) => DAYS_OF_WEEK[d]).join(", ");
|
||||
}
|
||||
|
||||
export function toCronExpression(cfg: TriggerConfig): string {
|
||||
const [h, m] = cfg.time.split(":");
|
||||
const hour = parseInt(h ?? "9", 10);
|
||||
@@ -129,11 +119,8 @@ export function toCronExpression(cfg: TriggerConfig): string {
|
||||
return `${min} ${hour} * * *`;
|
||||
case "weekdays":
|
||||
return `${min} ${hour} * * 1-5`;
|
||||
case "weekly": {
|
||||
const days = sortedDays(cfg.daysOfWeek);
|
||||
const dow = days.length > 0 ? days.join(",") : "1";
|
||||
return `${min} ${hour} * * ${dow}`;
|
||||
}
|
||||
case "weekly":
|
||||
return `${min} ${hour} * * ${cfg.dayOfWeek}`;
|
||||
case "custom":
|
||||
return cfg.cronExpression;
|
||||
}
|
||||
@@ -151,7 +138,7 @@ export function describeTrigger(cfg: TriggerConfig): string {
|
||||
case "weekdays":
|
||||
return `Runs weekdays at ${formatTime12h(cfg.time)} ${offset}`;
|
||||
case "weekly":
|
||||
return `Runs every ${formatDayList(cfg.daysOfWeek)} at ${formatTime12h(cfg.time)} ${offset}`;
|
||||
return `Runs every ${DAYS_OF_WEEK[cfg.dayOfWeek]} at ${formatTime12h(cfg.time)} ${offset}`;
|
||||
case "custom":
|
||||
return `Custom schedule: ${cfg.cronExpression}`;
|
||||
}
|
||||
@@ -264,39 +251,26 @@ export function TriggerConfigSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day-of-week multi-selector for weekly */}
|
||||
{/* Day-of-week selector for weekly */}
|
||||
{config.frequency === "weekly" && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Days</label>
|
||||
<label className="text-xs text-muted-foreground">Day</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{DAYS_OF_WEEK.map((day, i) => {
|
||||
const selected = config.daysOfWeek.includes(i);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
selected
|
||||
? "bg-foreground text-background"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
const next = selected
|
||||
? config.daysOfWeek.filter((d) => d !== i)
|
||||
: [...config.daysOfWeek, i];
|
||||
// Keep at least one day selected so the cron stays valid.
|
||||
onChange({
|
||||
...config,
|
||||
daysOfWeek: next.length > 0 ? next : config.daysOfWeek,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{DAYS_OF_WEEK.map((day, i) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
config.dayOfWeek === i
|
||||
? "bg-foreground text-background"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onChange({ ...config, dayOfWeek: i })}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,8 +32,6 @@ import { useEditorState } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { posToDOMRect } from "@tiptap/core";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateIssue } from "@multica/core/issues/mutations";
|
||||
import { Toggle } from "@multica/ui/components/ui/toggle";
|
||||
import { Separator } from "@multica/ui/components/ui/separator";
|
||||
import {
|
||||
@@ -66,8 +64,6 @@ import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
FilePlus,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -348,106 +344,11 @@ function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: E
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create Sub-Issue Button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Turns the current selection into a sub-issue of `parentIssueId` and replaces
|
||||
* the selection with a mention link to the new issue. Title is the selected
|
||||
* text (trimmed, collapsed whitespace, capped). Only rendered when a parent
|
||||
* issue is in scope; otherwise there's no meaningful "sub-issue of" target.
|
||||
*/
|
||||
function CreateSubIssueButton({
|
||||
editor,
|
||||
parentIssueId,
|
||||
}: {
|
||||
editor: Editor;
|
||||
parentIssueId: string;
|
||||
}) {
|
||||
const createIssue = useCreateIssue();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (pending) return;
|
||||
const { from, to } = editor.state.selection;
|
||||
if (from === to) return;
|
||||
|
||||
// Title from selection: collapse whitespace, cap length. The full selection
|
||||
// still becomes the link text — only the issue title is capped.
|
||||
const rawTitle = editor.state.doc.textBetween(from, to, " ", " ").trim();
|
||||
const title = rawTitle.replace(/\s+/g, " ").slice(0, 200);
|
||||
if (!title) return;
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const newIssue = await createIssue.mutateAsync({
|
||||
title,
|
||||
parent_issue_id: parentIssueId,
|
||||
});
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
{ from, to },
|
||||
[
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: newIssue.id,
|
||||
label: newIssue.identifier,
|
||||
type: "issue",
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
],
|
||||
)
|
||||
.run();
|
||||
toast.success(`Created ${newIssue.identifier}`);
|
||||
} catch {
|
||||
toast.error("Failed to create sub-issue");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [editor, parentIssueId, createIssue, pending]);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={false}
|
||||
disabled={pending}
|
||||
onPressedChange={handleClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<FilePlus className="size-3.5" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
Create sub-issue from selection
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Bubble Menu — @floating-ui/dom + portal to body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditorBubbleMenu({
|
||||
editor,
|
||||
currentIssueId,
|
||||
}: {
|
||||
editor: Editor;
|
||||
currentIssueId?: string;
|
||||
}) {
|
||||
function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
@@ -601,12 +502,6 @@ function EditorBubbleMenu({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>Quote</TooltipContent>
|
||||
</Tooltip>
|
||||
{currentIssueId && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
<CreateSubIssueButton editor={editor} parentIssueId={currentIssueId} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
@@ -75,12 +75,6 @@ interface ContentEditorProps {
|
||||
showBubbleMenu?: boolean;
|
||||
/** When true, bare Enter submits (chat-style). Mod-Enter always submits. */
|
||||
submitOnEnter?: boolean;
|
||||
/**
|
||||
* ID of the issue this editor belongs to. When set, the bubble menu exposes
|
||||
* a "Create sub-issue from selection" action that parents the new issue
|
||||
* under this ID and replaces the selection with a mention link.
|
||||
*/
|
||||
currentIssueId?: string;
|
||||
}
|
||||
|
||||
interface ContentEditorRef {
|
||||
@@ -110,7 +104,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onUploadFile,
|
||||
showBubbleMenu = true,
|
||||
submitOnEnter = false,
|
||||
currentIssueId,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -265,9 +258,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onMouseDown={handleContainerMouseDown}
|
||||
>
|
||||
<EditorContent className="flex-1 min-h-full" editor={editor} />
|
||||
{editable && showBubbleMenu && (
|
||||
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
|
||||
)}
|
||||
{editable && showBubbleMenu && <EditorBubbleMenu editor={editor} />}
|
||||
<LinkHoverCard {...hover} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
/**
|
||||
* Escape → blur the editor. Without this, pressing ESC inside the
|
||||
* contenteditable does nothing (browsers don't blur contenteditables by
|
||||
* default), leaving users stuck in the editor with no keyboard escape hatch.
|
||||
*/
|
||||
export function createBlurShortcutExtension() {
|
||||
return Extension.create({
|
||||
name: "blurShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Escape: ({ editor }) => {
|
||||
editor.commands.blur();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -40,7 +40,6 @@ import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
import { createMarkdownPasteExtension } from "./markdown-paste";
|
||||
import { createSubmitExtension } from "./submit-shortcut";
|
||||
import { createBlurShortcutExtension } from "./blur-shortcut";
|
||||
import { createFileUploadExtension } from "./file-upload";
|
||||
import { FileCardExtension } from "./file-card";
|
||||
import { ImageView } from "./image-view";
|
||||
@@ -138,7 +137,6 @@ export function createEditorExtensions(
|
||||
},
|
||||
{ submitOnEnter: options.submitOnEnter ?? false },
|
||||
),
|
||||
createBlurShortcutExtension(),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
import type { TimelineEntry, Attachment } from "@multica/core/types";
|
||||
import { useCommentCollapseStore } from "@multica/core/issues/stores";
|
||||
import { useCommentCollapseStore, replyDraftKey } from "@multica/core/issues/stores";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -291,7 +291,6 @@ function CommentRow({
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
@@ -512,7 +511,6 @@ function CommentCard({
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
@@ -569,6 +567,7 @@ function CommentCard({
|
||||
size="sm"
|
||||
avatarType="member"
|
||||
avatarId={currentUserId ?? ""}
|
||||
draftKey={replyDraftKey(issueId, entry.id)}
|
||||
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { ArrowUp, Loader2, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useCommentDraftStore, commentDraftKey } from "@multica/core/issues/stores";
|
||||
|
||||
interface CommentInputProps {
|
||||
issueId: string;
|
||||
@@ -16,10 +15,13 @@ interface CommentInputProps {
|
||||
}
|
||||
|
||||
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const draftKey = commentDraftKey(issueId);
|
||||
// Read the persisted draft once on mount so we can seed the editor. Later
|
||||
// updates go through setDraft / clearDraft without re-rendering from the store.
|
||||
const initialDraftRef = useRef<string>(useCommentDraftStore.getState().getDraft(draftKey));
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [isEmpty, setIsEmpty] = useState(!initialDraftRef.current.trim());
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
@@ -34,6 +36,11 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
|
||||
const handleUpdate = useCallback((md: string) => {
|
||||
setIsEmpty(!md.trim());
|
||||
useCommentDraftStore.getState().setDraft(draftKey, md);
|
||||
}, [draftKey]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
@@ -48,6 +55,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
uploadMapRef.current.clear();
|
||||
useCommentDraftStore.getState().clearDraft(draftKey);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -56,40 +64,20 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
return (
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg bg-card pb-8 ring-1 ring-border",
|
||||
isExpanded ? "h-[70vh]" : "max-h-56",
|
||||
)}
|
||||
className="relative flex max-h-56 flex-col rounded-lg bg-card pb-8 ring-1 ring-border"
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
placeholder="Leave a comment..."
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
defaultValue={initialDraftRef.current}
|
||||
onUpdate={handleUpdate}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => !v);
|
||||
editorRef.current?.focus();
|
||||
}}
|
||||
className="rounded-sm p-1.5 text-muted-foreground opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
|
||||
@@ -236,6 +236,27 @@ vi.mock("@multica/core/issues/stores", () => ({
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
useCommentDraftStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = {
|
||||
drafts: {},
|
||||
getDraft: () => "",
|
||||
setDraft: () => {},
|
||||
clearDraft: () => {},
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{
|
||||
getState: () => ({
|
||||
drafts: {},
|
||||
getDraft: () => "",
|
||||
setDraft: () => {},
|
||||
clearDraft: () => {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
commentDraftKey: (issueId: string) => `comment:${issueId}`,
|
||||
replyDraftKey: (issueId: string, commentId: string) => `reply:${issueId}:${commentId}`,
|
||||
}));
|
||||
|
||||
// Mock modals
|
||||
|
||||
@@ -697,12 +697,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
<span className="shrink-0">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{issue.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Tooltip>
|
||||
@@ -1049,7 +1046,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
onUpdate={(md) => handleUpdateField({ description: md })}
|
||||
onUploadFile={handleDescriptionUpload}
|
||||
debounceMs={1500}
|
||||
currentIssueId={id}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 mt-3">
|
||||
@@ -1273,14 +1269,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>
|
||||
|
||||
{/* Agent live output — sticky inside the Activity section so it
|
||||
stays pinned while scrolling through TaskRunHistory + comments.
|
||||
Keyed by issue id so switching issues remounts the card and
|
||||
clears any in-flight task state from the previous issue. */}
|
||||
<AgentLiveCard key={id} issueId={id} />
|
||||
stays pinned while scrolling through TaskRunHistory + comments. */}
|
||||
<AgentLiveCard issueId={id} />
|
||||
|
||||
{/* Agent execution history */}
|
||||
<div className="mt-3">
|
||||
<TaskRunHistory key={id} issueId={id} />
|
||||
<TaskRunHistory issueId={id} />
|
||||
</div>
|
||||
|
||||
{/* Timeline entries */}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { ArrowUp, Loader2, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useCommentDraftStore } from "@multica/core/issues/stores";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -21,6 +21,12 @@ interface ReplyInputProps {
|
||||
avatarId: string;
|
||||
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
size?: "sm" | "default";
|
||||
/**
|
||||
* Stable identifier used to persist the unsent draft across unmount
|
||||
* (e.g. switching issues or collapsing the parent comment). When omitted,
|
||||
* drafts are ephemeral.
|
||||
*/
|
||||
draftKey?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -34,11 +40,17 @@ function ReplyInput({
|
||||
avatarId,
|
||||
onSubmit,
|
||||
size = "default",
|
||||
draftKey,
|
||||
}: ReplyInputProps) {
|
||||
// Seed editor with any persisted draft once on mount. Omitted draftKey →
|
||||
// no persistence, preserving the old ephemeral behavior for callers that
|
||||
// don't want it.
|
||||
const initialDraftRef = useRef<string>(
|
||||
draftKey ? useCommentDraftStore.getState().getDraft(draftKey) : "",
|
||||
);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [hasOverflowContent, setHasOverflowContent] = useState(false);
|
||||
const [isEmpty, setIsEmpty] = useState(!initialDraftRef.current.trim());
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
@@ -52,7 +64,7 @@ function ReplyInput({
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setHasOverflowContent(entry.contentRect.height > 32);
|
||||
if (entry) setIsExpanded(entry.contentRect.height > 32);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
@@ -66,6 +78,11 @@ function ReplyInput({
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
|
||||
const handleUpdate = useCallback((md: string) => {
|
||||
setIsEmpty(!md.trim());
|
||||
if (draftKey) useCommentDraftStore.getState().setDraft(draftKey, md);
|
||||
}, [draftKey]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
@@ -80,6 +97,7 @@ function ReplyInput({
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
uploadMapRef.current.clear();
|
||||
if (draftKey) useCommentDraftStore.getState().clearDraft(draftKey);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -99,10 +117,8 @@ function ReplyInput({
|
||||
{...dropZoneProps}
|
||||
className={cn(
|
||||
"relative min-w-0 flex-1 flex flex-col",
|
||||
isExpanded
|
||||
? "h-[60vh]"
|
||||
: size === "sm" ? "max-h-40" : "max-h-56",
|
||||
(hasOverflowContent || isExpanded) && "pb-7",
|
||||
size === "sm" ? "max-h-40" : "max-h-56",
|
||||
isExpanded && "pb-7",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-14">
|
||||
@@ -110,32 +126,15 @@ function ReplyInput({
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
defaultValue={initialDraftRef.current}
|
||||
onUpdate={handleUpdate}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => !v);
|
||||
editorRef.current?.focus();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-sm opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
|
||||
@@ -111,20 +111,6 @@ function CursorLogo({ className }: { className: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Kimi (Moonshot AI) — wordmark "K" mark in Moonshot brand purple, simple
|
||||
// rounded-square logotype suitable for small icon sizes.
|
||||
function KimiLogo({ className }: { className: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect width="24" height="24" rx="5" fill="#1F1147" />
|
||||
<path
|
||||
d="M7.2 6h2.4v5.1l4.3-5.1h2.9l-4.4 5.1L17 18h-2.9l-3.2-5.2-1.3 1.5V18H7.2V6z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderLogo({
|
||||
provider,
|
||||
className = "h-4 w-4",
|
||||
@@ -149,8 +135,6 @@ export function ProviderLogo({
|
||||
return <CopilotLogo className={className} />;
|
||||
case "cursor":
|
||||
return <CursorLogo className={className} />;
|
||||
case "kimi":
|
||||
return <KimiLogo className={className} />;
|
||||
default:
|
||||
return <Monitor className={className} />;
|
||||
}
|
||||
|
||||
341
pnpm-lock.yaml
generated
341
pnpm-lock.yaml
generated
@@ -39,9 +39,6 @@ catalogs:
|
||||
lucide-react:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
posthog-js:
|
||||
specifier: ^1.176.1
|
||||
version: 1.369.3
|
||||
react:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3
|
||||
@@ -198,25 +195,25 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
apps/docs:
|
||||
dependencies:
|
||||
fumadocs-core:
|
||||
specifier: ^15.5.2
|
||||
version: 15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
version: 15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
fumadocs-mdx:
|
||||
specifier: ^12.0.3
|
||||
version: 12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
version: 12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
fumadocs-ui:
|
||||
specifier: ^15.5.2
|
||||
version: 15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.2.2)
|
||||
version: 15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.2.2)
|
||||
lucide-react:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.1(react@19.2.3)
|
||||
next:
|
||||
specifier: ^15.3.3
|
||||
version: 15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3
|
||||
@@ -358,7 +355,7 @@ importers:
|
||||
version: 1.0.1(react@19.2.3)
|
||||
next:
|
||||
specifier: ^16.2.3
|
||||
version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -440,7 +437,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
packages/core:
|
||||
dependencies:
|
||||
@@ -450,9 +447,6 @@ importers:
|
||||
'@tanstack/react-query-devtools':
|
||||
specifier: ^5.96.2
|
||||
version: 5.96.2(@tanstack/react-query@5.96.2(react@19.2.3))(react@19.2.3)
|
||||
posthog-js:
|
||||
specifier: 'catalog:'
|
||||
version: 1.369.3
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3
|
||||
@@ -471,7 +465,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
packages/eslint-config:
|
||||
dependencies:
|
||||
@@ -748,7 +742,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1740,78 +1734,6 @@ packages:
|
||||
'@open-draft/until@2.1.0':
|
||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/core@2.2.0':
|
||||
resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/core@2.7.0':
|
||||
resolution: {integrity: sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-http@0.208.0':
|
||||
resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-exporter-base@0.208.0':
|
||||
resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-transformer@0.208.0':
|
||||
resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/resources@2.2.0':
|
||||
resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/resources@2.7.0':
|
||||
resolution: {integrity: sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-logs@0.208.0':
|
||||
resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.4.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-metrics@2.2.0':
|
||||
resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.9.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-trace-base@2.2.0':
|
||||
resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0':
|
||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@orama/orama@3.1.18':
|
||||
resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==}
|
||||
engines: {node: '>= 20.0.0'}
|
||||
@@ -1828,42 +1750,6 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@posthog/core@1.25.2':
|
||||
resolution: {integrity: sha512-h2FO7ut/BbfwpAXWpwdDHTzQgUo9ibDFEs6ZO+3cI3KPWQt5XwczK1OLAuPprcjm8T/jl0SH8jSFo5XdU4RbTg==}
|
||||
|
||||
'@posthog/types@1.369.3':
|
||||
resolution: {integrity: sha512-Ywqvs4513PixR2TIA5O3GMEyK4F65uefwxPfsIUeHr9ruGylyXp00YJ4CEbp8U0DMzCkeF+LsMKVnHgN3pAXcA==}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
|
||||
'@protobufjs/base64@1.1.2':
|
||||
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||
|
||||
'@protobufjs/codegen@2.0.4':
|
||||
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0':
|
||||
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
|
||||
|
||||
'@protobufjs/fetch@1.1.0':
|
||||
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
|
||||
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/inquire@1.1.0':
|
||||
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.0':
|
||||
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
@@ -2926,9 +2812,6 @@ packages:
|
||||
'@types/statuses@2.0.6':
|
||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
@@ -3500,9 +3383,6 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
core-js@3.49.0:
|
||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||
|
||||
core-util-is@1.0.2:
|
||||
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
||||
|
||||
@@ -3736,9 +3616,6 @@ packages:
|
||||
dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
|
||||
dompurify@3.4.0:
|
||||
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
|
||||
|
||||
dotenv-expand@11.0.7:
|
||||
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4113,9 +3990,6 @@ packages:
|
||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||
engines: {node: ^12.20 || >= 14.13}
|
||||
|
||||
fflate@0.4.8:
|
||||
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
||||
|
||||
figures@6.1.0:
|
||||
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5012,9 +4886,6 @@ packages:
|
||||
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
long@5.3.2:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
@@ -5748,9 +5619,6 @@ packages:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
posthog-js@1.369.3:
|
||||
resolution: {integrity: sha512-t4vk8mgkSdhIYr8YDRdLG45uJYH58MC7bPL83lKTEeDgoejyXbJ1/G77GZB/aWVQDST055GkgjQtUtK5DiYGkg==}
|
||||
|
||||
postject@1.0.0-alpha.6:
|
||||
resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -5760,9 +5628,6 @@ packages:
|
||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
preact@10.29.1:
|
||||
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -5861,10 +5726,6 @@ packages:
|
||||
prosemirror-view@1.41.7:
|
||||
resolution: {integrity: sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==}
|
||||
|
||||
protobufjs@7.5.5:
|
||||
resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -5884,9 +5745,6 @@ packages:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
query-selector-shadow-dom@1.0.1:
|
||||
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -6892,9 +6750,6 @@ packages:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
web-vitals@5.2.0:
|
||||
resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==}
|
||||
|
||||
webidl-conversions@8.0.1:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -8021,82 +7876,6 @@ snapshots:
|
||||
|
||||
'@open-draft/until@2.1.0': {}
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
protobufjs: 7.5.5
|
||||
|
||||
'@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||
|
||||
'@orama/orama@3.1.18': {}
|
||||
|
||||
'@oxc-project/types@0.120.0': {}
|
||||
@@ -8108,33 +7887,6 @@ snapshots:
|
||||
dependencies:
|
||||
playwright: 1.58.2
|
||||
|
||||
'@posthog/core@1.25.2': {}
|
||||
|
||||
'@posthog/types@1.369.3': {}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
'@protobufjs/codegen@2.0.4': {}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0': {}
|
||||
|
||||
'@protobufjs/fetch@1.1.0':
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/inquire': 1.1.0
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/inquire@1.1.0': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.0': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
@@ -9157,9 +8909,6 @@ snapshots:
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
@@ -9808,8 +9557,6 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
core-js@3.49.0: {}
|
||||
|
||||
core-util-is@1.0.2:
|
||||
optional: true
|
||||
|
||||
@@ -10031,10 +9778,6 @@ snapshots:
|
||||
|
||||
dom-accessibility-api@0.6.3: {}
|
||||
|
||||
dompurify@3.4.0:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dotenv-expand@11.0.7:
|
||||
dependencies:
|
||||
dotenv: 16.6.1
|
||||
@@ -10629,8 +10372,6 @@ snapshots:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 3.3.3
|
||||
|
||||
fflate@0.4.8: {}
|
||||
|
||||
figures@6.1.0:
|
||||
dependencies:
|
||||
is-unicode-supported: 2.1.0
|
||||
@@ -10743,7 +10484,7 @@ snapshots:
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
|
||||
fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.6.2
|
||||
'@orama/orama': 3.1.18
|
||||
@@ -10766,21 +10507,21 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
lucide-react: 1.0.1(react@19.2.3)
|
||||
next: 15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next: 15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-router: 7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fumadocs-mdx@12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
|
||||
fumadocs-mdx@12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@mdx-js/mdx': 3.1.1
|
||||
'@standard-schema/spec': 1.1.0
|
||||
chokidar: 4.0.3
|
||||
esbuild: 0.25.12
|
||||
estree-util-value-to-estree: 3.5.0
|
||||
fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
js-yaml: 4.1.1
|
||||
lru-cache: 11.2.7
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
@@ -10793,12 +10534,12 @@ snapshots:
|
||||
unist-util-visit: 5.1.0
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
next: 15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next: 15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fumadocs-ui@15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.2.2):
|
||||
fumadocs-ui@15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.2.2):
|
||||
dependencies:
|
||||
'@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -10811,7 +10552,7 @@ snapshots:
|
||||
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
class-variance-authority: 0.7.1
|
||||
fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@1.0.1(react@19.2.3))(next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
lodash.merge: 4.6.2
|
||||
next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
postcss-selector-parser: 7.1.1
|
||||
@@ -10822,7 +10563,7 @@ snapshots:
|
||||
tailwind-merge: 3.5.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
next: 15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next: 15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
tailwindcss: 4.2.2
|
||||
transitivePeerDependencies:
|
||||
- '@mixedbread/sdk'
|
||||
@@ -11604,8 +11345,6 @@ snapshots:
|
||||
chalk: 5.6.2
|
||||
is-unicode-supported: 1.3.0
|
||||
|
||||
long@5.3.2: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
@@ -12244,7 +11983,7 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
next@15.5.15(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next@15.5.15(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@next/env': 15.5.15
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -12262,14 +12001,13 @@ snapshots:
|
||||
'@next/swc-linux-x64-musl': 15.5.15
|
||||
'@next/swc-win32-arm64-msvc': 15.5.15
|
||||
'@next/swc-win32-x64-msvc': 15.5.15
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.58.2
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@next/env': 16.2.3
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -12288,7 +12026,6 @@ snapshots:
|
||||
'@next/swc-linux-x64-musl': 16.2.3
|
||||
'@next/swc-win32-arm64-msvc': 16.2.3
|
||||
'@next/swc-win32-x64-msvc': 16.2.3
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.58.2
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
@@ -12631,22 +12368,6 @@ snapshots:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
posthog-js@1.369.3:
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@posthog/core': 1.25.2
|
||||
'@posthog/types': 1.369.3
|
||||
core-js: 3.49.0
|
||||
dompurify: 3.4.0
|
||||
fflate: 0.4.8
|
||||
preact: 10.29.1
|
||||
query-selector-shadow-dom: 1.0.1
|
||||
web-vitals: 5.2.0
|
||||
|
||||
postject@1.0.0-alpha.6:
|
||||
dependencies:
|
||||
commander: 9.5.0
|
||||
@@ -12654,8 +12375,6 @@ snapshots:
|
||||
|
||||
powershell-utils@0.1.0: {}
|
||||
|
||||
preact@10.29.1: {}
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
pretty-format@27.5.1:
|
||||
@@ -12803,21 +12522,6 @@ snapshots:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.11.0
|
||||
|
||||
protobufjs@7.5.5:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
'@protobufjs/codegen': 2.0.4
|
||||
'@protobufjs/eventemitter': 1.1.0
|
||||
'@protobufjs/fetch': 1.1.0
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/inquire': 1.1.0
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.0
|
||||
'@types/node': 25.5.0
|
||||
long: 5.3.2
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@@ -12836,8 +12540,6 @@ snapshots:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
query-selector-shadow-dom@1.0.1: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-lru@5.1.1: {}
|
||||
@@ -14015,7 +13717,7 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
|
||||
vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1)):
|
||||
vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.0
|
||||
'@vitest/mocker': 4.1.0(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
@@ -14038,7 +13740,6 @@ snapshots:
|
||||
vite: 8.0.1(@types/node@25.5.0)(jiti@2.6.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@types/node': 25.5.0
|
||||
jsdom: 29.0.1(@noble/hashes@1.8.0)
|
||||
transitivePeerDependencies:
|
||||
@@ -14058,8 +13759,6 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
web-vitals@5.2.0: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
whatwg-mimetype@5.0.0: {}
|
||||
|
||||
@@ -28,9 +28,6 @@ catalog:
|
||||
# Icons
|
||||
lucide-react: "^1.0.1"
|
||||
|
||||
# Product analytics
|
||||
posthog-js: "^1.176.1"
|
||||
|
||||
# Testing
|
||||
vitest: "^4.1.0"
|
||||
jsdom: "^29.0.1"
|
||||
|
||||
@@ -48,22 +48,14 @@ function Install-CliBinary {
|
||||
if (-not [Environment]::Is64BitOperatingSystem) {
|
||||
Write-Fail "Multica requires a 64-bit Windows installation."
|
||||
}
|
||||
|
||||
# Distinguish amd64 vs arm64 — Is64BitOperatingSystem is true for both.
|
||||
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
|
||||
switch ($osArch) {
|
||||
'X64' { $arch = "amd64" }
|
||||
'Arm64' { $arch = "arm64" }
|
||||
default { Write-Fail "Unsupported Windows architecture: $osArch (only X64 and Arm64 are supported)." }
|
||||
}
|
||||
$arch = "amd64"
|
||||
|
||||
$latest = Get-LatestVersion
|
||||
if (-not $latest) {
|
||||
Write-Fail "Could not determine latest release. Check your network connection."
|
||||
}
|
||||
|
||||
$version = $latest.TrimStart('v')
|
||||
$url = "https://github.com/multica-ai/multica/releases/download/$latest/multica-cli-$version-windows-$arch.zip"
|
||||
$url = "https://github.com/multica-ai/multica/releases/download/$latest/multica_windows_$arch.zip"
|
||||
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "multica-install"
|
||||
|
||||
if (Test-Path $tmpDir) { Remove-Item $tmpDir -Recurse -Force }
|
||||
@@ -83,7 +75,7 @@ function Install-CliBinary {
|
||||
$checksums = Invoke-WebRequest -Uri $checksumUrl -UseBasicParsing -ErrorAction Stop
|
||||
$zipFile = Join-Path $tmpDir "multica.zip"
|
||||
$actualHash = (Get-FileHash -Path $zipFile -Algorithm SHA256).Hash.ToLower()
|
||||
$expectedLine = ($checksums.Content -split "`n") | Where-Object { $_ -match "multica-cli-$version-windows-$arch\.zip" } | Select-Object -First 1
|
||||
$expectedLine = ($checksums.Content -split "`n") | Where-Object { $_ -match "multica_windows_$arch\.zip" } | Select-Object -First 1
|
||||
if ($expectedLine) {
|
||||
$expectedHash = ($expectedLine -split "\s+")[0].ToLower()
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
|
||||
@@ -90,8 +90,7 @@ install_cli_binary() {
|
||||
fail "Could not determine latest release. Check your network connection."
|
||||
fi
|
||||
|
||||
local version="${latest#v}"
|
||||
local url="https://github.com/multica-ai/multica/releases/download/${latest}/multica-cli-${version}-${OS}-${ARCH}.tar.gz"
|
||||
local url="https://github.com/multica-ai/multica/releases/download/${latest}/multica_${OS}_${ARCH}.tar.gz"
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
|
||||
|
||||
@@ -114,8 +114,7 @@ func init() {
|
||||
agentCreateCmd.Flags().String("instructions", "", "Agent instructions")
|
||||
agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)")
|
||||
agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string")
|
||||
agentCreateCmd.Flags().String("model", "", "Model identifier (e.g. claude-sonnet-4-6, openai/gpt-4o). Prefer this over passing --model in --custom-args.")
|
||||
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
|
||||
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array (e.g. '[\"--model\", \"o3\"]')")
|
||||
agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace")
|
||||
agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks")
|
||||
agentCreateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
@@ -126,8 +125,7 @@ func init() {
|
||||
agentUpdateCmd.Flags().String("instructions", "", "New instructions")
|
||||
agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID")
|
||||
agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string")
|
||||
agentUpdateCmd.Flags().String("model", "", "New model identifier. Pass an empty string to clear and fall back to the runtime default.")
|
||||
agentUpdateCmd.Flags().String("custom-args", "", "New custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
|
||||
agentUpdateCmd.Flags().String("custom-args", "", "New custom CLI arguments as JSON array (e.g. '[\"--model\", \"o3\"]')")
|
||||
agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace")
|
||||
agentUpdateCmd.Flags().String("status", "", "New status")
|
||||
agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks")
|
||||
@@ -199,27 +197,11 @@ func normalizeAPIBaseURL(raw string) string {
|
||||
return raw
|
||||
}
|
||||
|
||||
// inAgentExecutionContext reports whether the CLI is being invoked from
|
||||
// inside a daemon-managed agent task (daemon sets MULTICA_AGENT_ID and
|
||||
// MULTICA_TASK_ID in the agent env). In that context the workspace must be
|
||||
// provided explicitly by the daemon — falling back to user-global
|
||||
// ~/.multica/config.json would let the agent act on whatever workspace the
|
||||
// user last configured, which is how cross-workspace contamination happens
|
||||
// when multiple workspaces share a host.
|
||||
func inAgentExecutionContext() bool {
|
||||
return os.Getenv("MULTICA_AGENT_ID") != "" || os.Getenv("MULTICA_TASK_ID") != ""
|
||||
}
|
||||
|
||||
func resolveWorkspaceID(cmd *cobra.Command) string {
|
||||
val := cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", "")
|
||||
if val != "" {
|
||||
return val
|
||||
}
|
||||
// Inside an agent task the daemon is the only authority on workspace
|
||||
// identity. Never read the user-global CLI config here.
|
||||
if inAgentExecutionContext() {
|
||||
return ""
|
||||
}
|
||||
profile := resolveProfile(cmd)
|
||||
cfg, _ := cli.LoadCLIConfigForProfile(profile)
|
||||
return cfg.WorkspaceID
|
||||
@@ -231,9 +213,6 @@ func resolveWorkspaceID(cmd *cobra.Command) string {
|
||||
func requireWorkspaceID(cmd *cobra.Command) (string, error) {
|
||||
id := resolveWorkspaceID(cmd)
|
||||
if id == "" {
|
||||
if inAgentExecutionContext() {
|
||||
return "", fmt.Errorf("workspace_id is required: MULTICA_WORKSPACE_ID must be set by the daemon in agent execution context (no fallback to user config)")
|
||||
}
|
||||
return "", fmt.Errorf("workspace_id is required: use --workspace-id flag, set MULTICA_WORKSPACE_ID env, or run 'multica config set workspace_id <id>'")
|
||||
}
|
||||
return id, nil
|
||||
@@ -368,10 +347,6 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
body["custom_args"] = ca
|
||||
}
|
||||
if cmd.Flags().Changed("model") {
|
||||
v, _ := cmd.Flags().GetString("model")
|
||||
body["model"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("visibility") {
|
||||
v, _ := cmd.Flags().GetString("visibility")
|
||||
body["visibility"] = v
|
||||
@@ -437,10 +412,6 @@ func runAgentUpdate(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
body["custom_args"] = ca
|
||||
}
|
||||
if cmd.Flags().Changed("model") {
|
||||
v, _ := cmd.Flags().GetString("model")
|
||||
body["model"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("visibility") {
|
||||
v, _ := cmd.Flags().GetString("visibility")
|
||||
body["visibility"] = v
|
||||
@@ -455,7 +426,7 @@ func runAgentUpdate(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --model, --custom-args, --visibility, --status, or --max-concurrent-tasks")
|
||||
return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --custom-args, --visibility, --status, or --max-concurrent-tasks")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
// TestResolveWorkspaceID_AgentContextSkipsConfig is a regression test for
|
||||
// the cross-workspace contamination bug (#1235). Inside a daemon-spawned
|
||||
// agent task (MULTICA_AGENT_ID / MULTICA_TASK_ID set), the CLI must NOT
|
||||
// silently read the user-global ~/.multica/config.json to recover a missing
|
||||
// workspace — that fallback is how agent operations leaked into an
|
||||
// unrelated workspace when the daemon failed to inject the right value.
|
||||
//
|
||||
// Outside agent context, the three-level fallback (flag → env → config) is
|
||||
// unchanged.
|
||||
func TestResolveWorkspaceID_AgentContextSkipsConfig(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
// Seed the global CLI config with a workspace_id that must NOT be
|
||||
// picked up while running inside an agent task.
|
||||
if err := cli.SaveCLIConfig(cli.CLIConfig{WorkspaceID: "config-file-ws"}); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
t.Run("outside agent context falls back to config", func(t *testing.T) {
|
||||
t.Setenv("MULTICA_AGENT_ID", "")
|
||||
t.Setenv("MULTICA_TASK_ID", "")
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "")
|
||||
|
||||
got := resolveWorkspaceID(testCmd())
|
||||
if got != "config-file-ws" {
|
||||
t.Fatalf("resolveWorkspaceID() = %q, want %q (config fallback)", got, "config-file-ws")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent context with explicit env uses env", func(t *testing.T) {
|
||||
t.Setenv("MULTICA_AGENT_ID", "agent-123")
|
||||
t.Setenv("MULTICA_TASK_ID", "task-456")
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "env-ws")
|
||||
|
||||
got := resolveWorkspaceID(testCmd())
|
||||
if got != "env-ws" {
|
||||
t.Fatalf("resolveWorkspaceID() = %q, want %q (env)", got, "env-ws")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent context without env returns empty, never config", func(t *testing.T) {
|
||||
t.Setenv("MULTICA_AGENT_ID", "agent-123")
|
||||
t.Setenv("MULTICA_TASK_ID", "task-456")
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "")
|
||||
|
||||
got := resolveWorkspaceID(testCmd())
|
||||
if got != "" {
|
||||
t.Fatalf("resolveWorkspaceID() = %q, want empty (no silent config fallback in agent context)", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("task marker alone also counts as agent context", func(t *testing.T) {
|
||||
t.Setenv("MULTICA_AGENT_ID", "")
|
||||
t.Setenv("MULTICA_TASK_ID", "task-456")
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "")
|
||||
|
||||
if got := resolveWorkspaceID(testCmd()); got != "" {
|
||||
t.Fatalf("resolveWorkspaceID() = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("requireWorkspaceID surfaces agent-context error", func(t *testing.T) {
|
||||
t.Setenv("MULTICA_AGENT_ID", "agent-123")
|
||||
t.Setenv("MULTICA_TASK_ID", "task-456")
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "")
|
||||
|
||||
_, err := requireWorkspaceID(testCmd())
|
||||
if err == nil {
|
||||
t.Fatal("requireWorkspaceID(): expected error inside agent context with empty env, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "agent execution context") {
|
||||
t.Fatalf("requireWorkspaceID() error = %q, want it to mention agent execution context", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -78,8 +78,8 @@ func openBrowser(url string) error {
|
||||
cmd = "xdg-open"
|
||||
args = []string{url}
|
||||
case "windows":
|
||||
cmd = "rundll32"
|
||||
args = []string{"url.dll,FileProtocolHandler", url}
|
||||
cmd = "cmd"
|
||||
args = []string{"/c", "start", "", url}
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
@@ -360,19 +360,17 @@ func runDaemonRestart(cmd *cobra.Command, args []string) error {
|
||||
if health["status"] == "running" {
|
||||
pid, _ := health["pid"].(float64)
|
||||
if pid > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid))
|
||||
if err := requestDaemonShutdown(healthPort); err != nil {
|
||||
if p, perr := os.FindProcess(int(pid)); perr == nil {
|
||||
_ = p.Kill()
|
||||
}
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
sctx, scancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
h := checkDaemonHealthOnPort(sctx, healthPort)
|
||||
scancel()
|
||||
if h["status"] != "running" {
|
||||
break
|
||||
if p, err := os.FindProcess(int(pid)); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid))
|
||||
_ = stopDaemonProcess(p)
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
sctx, scancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
h := checkDaemonHealthOnPort(sctx, healthPort)
|
||||
scancel()
|
||||
if h["status"] != "running" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,17 +409,8 @@ func runDaemonStop(cmd *cobra.Command, _ []string) error {
|
||||
return fmt.Errorf("find process %d: %w", int(pid), err)
|
||||
}
|
||||
|
||||
// Request graceful shutdown via the daemon's HTTP /shutdown endpoint
|
||||
// rather than an OS signal. On Windows the daemon is spawned with
|
||||
// DETACHED_PROCESS so it shares no console with us, which means
|
||||
// GenerateConsoleCtrlEvent can't reach it; HTTP works on both
|
||||
// platforms and triggers the same context-cancel path the daemon
|
||||
// already uses for self-restart.
|
||||
if err := requestDaemonShutdown(healthPort); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Graceful shutdown request failed: %v — falling back to forced kill.\n", err)
|
||||
if kerr := process.Kill(); kerr != nil {
|
||||
return fmt.Errorf("kill daemon (pid %d): %w", int(pid), kerr)
|
||||
}
|
||||
if err := stopDaemonProcess(process); err != nil {
|
||||
return fmt.Errorf("stop daemon (pid %d): %w", int(pid), err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid))
|
||||
@@ -443,27 +432,6 @@ func runDaemonStop(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestDaemonShutdown POSTs to the daemon's /shutdown endpoint to ask it
|
||||
// to exit gracefully. Returns an error if the request could not be delivered
|
||||
// (network error, non-2xx status, or the endpoint predates this change).
|
||||
func requestDaemonShutdown(healthPort int) error {
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/shutdown", healthPort)
|
||||
req, err := http.NewRequest(http.MethodPost, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- daemon status ---
|
||||
|
||||
func runDaemonStatus(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
@@ -15,6 +15,10 @@ func daemonSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
|
||||
func stopDaemonProcess(process *os.Process) error {
|
||||
return process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
|
||||
func notifyShutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
|
||||
}
|
||||
|
||||
@@ -12,25 +12,30 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
detachedProcess = 0x00000008
|
||||
sigBreak = syscall.Signal(0x15)
|
||||
createNewProcessGroup = 0x00000200
|
||||
ctrlBreakEvent = 1
|
||||
sigBreak = syscall.Signal(0x15)
|
||||
)
|
||||
|
||||
// daemonSysProcAttr returns the attributes used when spawning the background
|
||||
// daemon. DETACHED_PROCESS severs the inherited console so closing the parent
|
||||
// cmd/PowerShell window no longer propagates CTRL_CLOSE_EVENT to the daemon.
|
||||
// Because the detached daemon shares no console with the stop caller,
|
||||
// `daemon stop` talks to it via the HTTP /shutdown endpoint rather than
|
||||
// GenerateConsoleCtrlEvent. The daemon's stdout/stderr are already
|
||||
// redirected to the log file before Start() is called, so losing the
|
||||
// console is safe.
|
||||
func daemonSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: detachedProcess,
|
||||
CreationFlags: createNewProcessGroup,
|
||||
}
|
||||
}
|
||||
|
||||
func stopDaemonProcess(process *os.Process) error {
|
||||
// Try graceful shutdown via CTRL_BREAK_EVENT first.
|
||||
// The daemon's process group ID matches its PID (CREATE_NEW_PROCESS_GROUP).
|
||||
dll := syscall.NewLazyDLL("kernel32.dll")
|
||||
generateCtrlEvent := dll.NewProc("GenerateConsoleCtrlEvent")
|
||||
ret, _, _ := generateCtrlEvent.Call(uintptr(ctrlBreakEvent), uintptr(process.Pid))
|
||||
if ret != 0 {
|
||||
return nil
|
||||
}
|
||||
return process.Kill()
|
||||
}
|
||||
|
||||
func notifyShutdownContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(parent, os.Interrupt, sigBreak)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -75,7 +73,6 @@ func init() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
cli.CleanupStaleUpdateArtifacts()
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
if err != errSilent {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
// dbStatsInterval is how often the pool stats are sampled and logged.
|
||||
// 15s lines up with the daemon heartbeat cadence so it's easy to
|
||||
// correlate with traffic patterns in the prod logs.
|
||||
dbStatsInterval = 15 * time.Second
|
||||
|
||||
// defaultMaxConns / defaultMinConns are the per-pod pgxpool sizing
|
||||
// defaults. They replace pgx's built-in default of max(4, NumCPU),
|
||||
// which is far too small for our daemon-poll traffic pattern (~3800
|
||||
// acquires/s observed in prod) and was the root cause of the 3s+
|
||||
// /tasks/claim tail latency.
|
||||
//
|
||||
// The numbers follow the conventional "small pool, lots of waiters"
|
||||
// guidance for Postgres (HikariCP / PG community formula
|
||||
// `(core_count * 2) + effective_spindle_count`): 25 leaves headroom
|
||||
// for bursts and the occasional long-running query while staying well
|
||||
// below typical managed-Postgres `max_connections` ceilings when
|
||||
// multiplied across pods. MinConns=5 keeps a warm baseline so cold
|
||||
// pods don't pay handshake cost on first traffic.
|
||||
//
|
||||
// Both values are overridable via DATABASE_MAX_CONNS / DATABASE_MIN_CONNS.
|
||||
defaultMaxConns int32 = 25
|
||||
defaultMinConns int32 = 5
|
||||
)
|
||||
|
||||
// newDBPool builds a pgxpool with sane production defaults and env overrides.
|
||||
//
|
||||
// pgxpool.New(ctx, url) — used previously — silently picks MaxConns =
|
||||
// max(4, NumCPU). On our prod pods (small CPU request) that resolved to 4,
|
||||
// which got fully saturated by the daemon claim/heartbeat traffic and showed
|
||||
// up as ~900ms acquire waits on every query.
|
||||
//
|
||||
// Configuration precedence (highest first):
|
||||
// 1. DATABASE_MAX_CONNS / DATABASE_MIN_CONNS env vars
|
||||
// 2. pool_max_conns / pool_min_conns query params on DATABASE_URL
|
||||
// (honored natively by pgxpool.ParseConfig)
|
||||
// 3. The defaults defined here (defaultMaxConns / defaultMinConns)
|
||||
//
|
||||
// pgx's own built-in default (max(4, NumCPU)) is intentionally NOT used as a
|
||||
// fallback — it is the value that caused the prod incident.
|
||||
func newDBPool(ctx context.Context, dbURL string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dbURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database url: %w", err)
|
||||
}
|
||||
|
||||
urlParams := poolParamsFromURL(dbURL)
|
||||
|
||||
// Compute the non-env fallback first: honor URL pool_* params if the
|
||||
// operator set them, otherwise use our code default. This fallback is
|
||||
// also what an *invalid* env value falls back to — never pgx's built-in
|
||||
// default of 4/0, which is the value that caused the prod incident.
|
||||
maxFallback := defaultMaxConns
|
||||
if urlParams["pool_max_conns"] {
|
||||
maxFallback = cfg.MaxConns
|
||||
}
|
||||
cfg.MaxConns = envInt32("DATABASE_MAX_CONNS", maxFallback)
|
||||
|
||||
minFallback := defaultMinConns
|
||||
if urlParams["pool_min_conns"] {
|
||||
minFallback = cfg.MinConns
|
||||
}
|
||||
cfg.MinConns = envInt32("DATABASE_MIN_CONNS", minFallback)
|
||||
|
||||
if cfg.MinConns > cfg.MaxConns {
|
||||
cfg.MinConns = cfg.MaxConns
|
||||
}
|
||||
|
||||
return pgxpool.NewWithConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
// poolParamsFromURL returns the set of pool_* query params present on the
|
||||
// database URL. Used to detect whether the operator already tuned the pool
|
||||
// via the connection string, so env-less upgrades don't silently override
|
||||
// existing configuration.
|
||||
func poolParamsFromURL(dbURL string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
u, err := url.Parse(dbURL)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for k := range u.Query() {
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// envInt32 reads an int32 from the named env var. Empty / invalid values fall
|
||||
// back to def and emit a warn so misconfiguration is visible in startup logs.
|
||||
func envInt32(name string, def int32) int32 {
|
||||
raw := os.Getenv(name)
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.ParseInt(raw, 10, 32)
|
||||
if err != nil || v <= 0 {
|
||||
slog.Warn("invalid env var, using default",
|
||||
"name", name, "value", raw, "default", def, "error", err)
|
||||
return def
|
||||
}
|
||||
return int32(v)
|
||||
}
|
||||
|
||||
// logPoolConfig prints the effective pgxpool configuration once at startup.
|
||||
// Surfacing this is critical because pgxpool defaults are surprisingly small
|
||||
// (MaxConns = max(4, NumCPU)) — without seeing the value in the log it's
|
||||
// easy to mistake pool exhaustion for "the database is slow".
|
||||
func logPoolConfig(pool *pgxpool.Pool) {
|
||||
cfg := pool.Config()
|
||||
slog.Info("db pool config",
|
||||
"max_conns", cfg.MaxConns,
|
||||
"min_conns", cfg.MinConns,
|
||||
"max_conn_lifetime", cfg.MaxConnLifetime.String(),
|
||||
"max_conn_idle_time", cfg.MaxConnIdleTime.String(),
|
||||
"health_check_period", cfg.HealthCheckPeriod.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// runDBStatsLogger samples pool.Stat() periodically. It always emits an INFO
|
||||
// line so operators can see baseline pressure, and emits a WARN whenever the
|
||||
// EmptyAcquireCount delta is positive — that's the direct symptom of pool
|
||||
// exhaustion (a request had to wait because no idle conn was available) and
|
||||
// the smoking gun we're looking for to confirm the slow /tasks/claim
|
||||
// hypothesis.
|
||||
func runDBStatsLogger(ctx context.Context, pool *pgxpool.Pool) {
|
||||
ticker := time.NewTicker(dbStatsInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
var (
|
||||
lastEmpty int64
|
||||
lastAcquire int64
|
||||
lastAcquireDur time.Duration
|
||||
lastCanceled int64
|
||||
)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
s := pool.Stat()
|
||||
emptyDelta := s.EmptyAcquireCount() - lastEmpty
|
||||
acquireDelta := s.AcquireCount() - lastAcquire
|
||||
acquireDurDelta := s.AcquireDuration() - lastAcquireDur
|
||||
canceledDelta := s.CanceledAcquireCount() - lastCanceled
|
||||
|
||||
// Average wait per acquire over the last sampling window. Useful
|
||||
// because cumulative AcquireDuration alone hides whether the
|
||||
// situation is improving or worsening.
|
||||
var avgAcquireMs int64
|
||||
if acquireDelta > 0 {
|
||||
avgAcquireMs = (acquireDurDelta).Milliseconds() / acquireDelta
|
||||
}
|
||||
|
||||
fields := []any{
|
||||
"max_conns", s.MaxConns(),
|
||||
"total_conns", s.TotalConns(),
|
||||
"acquired_conns", s.AcquiredConns(),
|
||||
"idle_conns", s.IdleConns(),
|
||||
"constructing_conns", s.ConstructingConns(),
|
||||
"acquire_count_delta", acquireDelta,
|
||||
"empty_acquire_delta", emptyDelta,
|
||||
"canceled_acquire_delta", canceledDelta,
|
||||
"avg_acquire_ms", avgAcquireMs,
|
||||
}
|
||||
|
||||
if emptyDelta > 0 || canceledDelta > 0 {
|
||||
slog.Warn("db pool pressure", fields...)
|
||||
} else {
|
||||
slog.Info("db pool stats", fields...)
|
||||
}
|
||||
|
||||
lastEmpty = s.EmptyAcquireCount()
|
||||
lastAcquire = s.AcquireCount()
|
||||
lastAcquireDur = s.AcquireDuration()
|
||||
lastCanceled = s.CanceledAcquireCount()
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// applyPoolSizing mirrors the env+URL precedence logic in newDBPool but
|
||||
// without actually opening a connection, so the resolution rules can be
|
||||
// asserted in unit tests.
|
||||
func applyPoolSizing(t *testing.T, dbURL string, envMax, envMin string) (max, min int32) {
|
||||
t.Helper()
|
||||
cfg, err := pgxpool.ParseConfig(dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig: %v", err)
|
||||
}
|
||||
urlParams := poolParamsFromURL(dbURL)
|
||||
|
||||
maxFallback := defaultMaxConns
|
||||
if urlParams["pool_max_conns"] {
|
||||
maxFallback = cfg.MaxConns
|
||||
}
|
||||
if envMax != "" {
|
||||
t.Setenv("DATABASE_MAX_CONNS", envMax)
|
||||
}
|
||||
cfg.MaxConns = envInt32("DATABASE_MAX_CONNS", maxFallback)
|
||||
|
||||
minFallback := defaultMinConns
|
||||
if urlParams["pool_min_conns"] {
|
||||
minFallback = cfg.MinConns
|
||||
}
|
||||
if envMin != "" {
|
||||
t.Setenv("DATABASE_MIN_CONNS", envMin)
|
||||
}
|
||||
cfg.MinConns = envInt32("DATABASE_MIN_CONNS", minFallback)
|
||||
|
||||
if cfg.MinConns > cfg.MaxConns {
|
||||
cfg.MinConns = cfg.MaxConns
|
||||
}
|
||||
return cfg.MaxConns, cfg.MinConns
|
||||
}
|
||||
|
||||
func TestPoolSizing_DefaultsWhenNothingSet(t *testing.T) {
|
||||
max, min := applyPoolSizing(t, "postgres://u:p@h/db?sslmode=disable", "", "")
|
||||
if max != defaultMaxConns || min != defaultMinConns {
|
||||
t.Fatalf("got max=%d min=%d, want %d/%d", max, min, defaultMaxConns, defaultMinConns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSizing_URLParamsHonoredWhenEnvUnset(t *testing.T) {
|
||||
url := "postgres://u:p@h/db?sslmode=disable&pool_max_conns=40&pool_min_conns=8"
|
||||
max, min := applyPoolSizing(t, url, "", "")
|
||||
if max != 40 || min != 8 {
|
||||
t.Fatalf("URL params should win when env unset; got max=%d min=%d", max, min)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSizing_EnvOverridesURL(t *testing.T) {
|
||||
url := "postgres://u:p@h/db?sslmode=disable&pool_max_conns=40&pool_min_conns=8"
|
||||
max, min := applyPoolSizing(t, url, "100", "20")
|
||||
if max != 100 || min != 20 {
|
||||
t.Fatalf("env should win over URL; got max=%d min=%d", max, min)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSizing_PartialURLParam(t *testing.T) {
|
||||
// Only pool_max_conns is set in URL — pool_min_conns should fall back to
|
||||
// the code default, not pgx's built-in default (which would be 0).
|
||||
url := "postgres://u:p@h/db?sslmode=disable&pool_max_conns=40"
|
||||
max, min := applyPoolSizing(t, url, "", "")
|
||||
if max != 40 {
|
||||
t.Fatalf("URL pool_max_conns should be honored; got max=%d", max)
|
||||
}
|
||||
if min != defaultMinConns {
|
||||
t.Fatalf("min should default; got min=%d, want %d", min, defaultMinConns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSizing_InvalidEnvFallsBackToCodeDefault(t *testing.T) {
|
||||
// Invalid env value with no URL pool param → code default, NOT pgx's
|
||||
// built-in 4. This is the regression that was fixed; pinning it here
|
||||
// so we don't silently fall back to the bad value again.
|
||||
max, min := applyPoolSizing(t, "postgres://u:p@h/db?sslmode=disable", "not-a-number", "")
|
||||
if max != defaultMaxConns {
|
||||
t.Fatalf("invalid env should fall back to code default; got max=%d, want %d", max, defaultMaxConns)
|
||||
}
|
||||
if min != defaultMinConns {
|
||||
t.Fatalf("got min=%d, want %d", min, defaultMinConns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSizing_InvalidEnvFallsBackToURLParam(t *testing.T) {
|
||||
// Invalid env value with a URL pool param → URL param wins, NOT pgx
|
||||
// default. This is what makes the precedence chain end at "URL or code
|
||||
// default" rather than at "pgx default" on misconfiguration.
|
||||
url := "postgres://u:p@h/db?sslmode=disable&pool_max_conns=40"
|
||||
max, _ := applyPoolSizing(t, url, "not-a-number", "")
|
||||
if max != 40 {
|
||||
t.Fatalf("invalid env should fall back to URL param; got max=%d, want 40", max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSizing_MinClampedToMax(t *testing.T) {
|
||||
max, min := applyPoolSizing(t, "postgres://u:p@h/db?sslmode=disable", "10", "50")
|
||||
if min > max {
|
||||
t.Fatalf("min should be clamped to max; got max=%d min=%d", max, min)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/analytics"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
@@ -71,7 +70,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
bus := events.New()
|
||||
registerListeners(bus, hub)
|
||||
router := NewRouter(pool, hub, bus, analytics.NoopClient{})
|
||||
router := NewRouter(pool, hub, bus)
|
||||
testServer = httptest.NewServer(router)
|
||||
|
||||
// Generate a JWT token directly for the test user
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/analytics"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
@@ -40,7 +41,7 @@ func main() {
|
||||
|
||||
// Connect to database
|
||||
ctx := context.Background()
|
||||
pool, err := newDBPool(ctx, dbURL)
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
slog.Error("unable to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -52,16 +53,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("connected to database")
|
||||
logPoolConfig(pool)
|
||||
|
||||
bus := events.New()
|
||||
hub := realtime.NewHub()
|
||||
go hub.Run()
|
||||
registerListeners(bus, hub)
|
||||
|
||||
analyticsClient := analytics.NewFromEnv()
|
||||
defer analyticsClient.Close()
|
||||
|
||||
queries := db.New(pool)
|
||||
// Order matters: subscriber listeners must register BEFORE notification listeners.
|
||||
// The notification listener queries the subscriber table to determine recipients,
|
||||
@@ -70,7 +67,7 @@ func main() {
|
||||
registerActivityListeners(bus, queries)
|
||||
registerNotificationListeners(bus, queries)
|
||||
|
||||
r := NewRouter(pool, hub, bus, analyticsClient)
|
||||
r := NewRouter(pool, hub, bus)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
@@ -80,14 +77,13 @@ func main() {
|
||||
// Start background workers.
|
||||
sweepCtx, sweepCancel := context.WithCancel(context.Background())
|
||||
autopilotCtx, autopilotCancel := context.WithCancel(context.Background())
|
||||
taskSvc := service.NewTaskService(queries, pool, hub, bus)
|
||||
taskSvc := service.NewTaskService(queries, hub, bus)
|
||||
autopilotSvc := service.NewAutopilotService(queries, pool, bus, taskSvc)
|
||||
registerAutopilotListeners(bus, autopilotSvc)
|
||||
|
||||
// Start background sweeper to mark stale runtimes as offline.
|
||||
go runRuntimeSweeper(sweepCtx, queries, bus)
|
||||
go runAutopilotScheduler(autopilotCtx, queries, autopilotSvc)
|
||||
go runDBStatsLogger(sweepCtx, pool)
|
||||
|
||||
// Graceful shutdown
|
||||
go func() {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/analytics"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/handler"
|
||||
@@ -54,7 +53,7 @@ func allowedOrigins() []string {
|
||||
}
|
||||
|
||||
// NewRouter creates the fully-configured Chi router with all middleware and routes.
|
||||
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analyticsClient analytics.Client) chi.Router {
|
||||
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
|
||||
queries := db.New(pool)
|
||||
emailSvc := service.NewEmailService()
|
||||
|
||||
@@ -71,13 +70,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics
|
||||
}
|
||||
|
||||
cfSigner := auth.NewCloudFrontSignerFromEnv()
|
||||
|
||||
signupConfig := handler.Config{
|
||||
AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false",
|
||||
AllowedEmails: splitAndTrim(os.Getenv("ALLOWED_EMAILS")),
|
||||
AllowedEmailDomains: splitAndTrim(os.Getenv("ALLOWED_EMAIL_DOMAINS")),
|
||||
}
|
||||
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner, analyticsClient, signupConfig)
|
||||
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -146,7 +139,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics
|
||||
r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime)
|
||||
r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult)
|
||||
r.Post("/runtimes/{runtimeId}/update/{updateId}/result", h.ReportUpdateResult)
|
||||
r.Post("/runtimes/{runtimeId}/models/{requestId}/result", h.ReportModelListResult)
|
||||
|
||||
r.Get("/tasks/{taskId}/status", h.GetTaskStatus)
|
||||
r.Post("/tasks/{taskId}/start", h.StartTask)
|
||||
@@ -348,8 +340,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics
|
||||
r.Get("/ping/{pingId}", h.GetPing)
|
||||
r.Post("/update", h.InitiateUpdate)
|
||||
r.Get("/update/{updateId}", h.GetUpdate)
|
||||
r.Post("/models", h.InitiateListModels)
|
||||
r.Get("/models/{requestId}", h.GetModelListRequest)
|
||||
r.Delete("/", h.DeleteAgentRuntime)
|
||||
})
|
||||
})
|
||||
@@ -424,18 +414,3 @@ func parseUUID(s string) pgtype.UUID {
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func splitAndTrim(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
res := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
res = append(res, trimmed)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// Package analytics ships product telemetry events to an external analytics
|
||||
// backend (PostHog). Events feed the acquisition → activation → expansion
|
||||
// funnel — see docs/analytics.md for the event contract.
|
||||
//
|
||||
// Design:
|
||||
// - Capture is non-blocking. Request handlers must never wait on analytics
|
||||
// network I/O, so we enqueue into a bounded channel and a background
|
||||
// worker flushes to PostHog in batches.
|
||||
// - When the queue is full events are dropped (and counted). A broken
|
||||
// analytics backend must never degrade the product.
|
||||
// - When POSTHOG_API_KEY is empty the package runs a no-op client, which
|
||||
// keeps local dev and self-hosted instances friction-free.
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Event is a single analytics capture. Fields mirror PostHog's /capture/ shape
|
||||
// but are framework-agnostic so alternate backends can plug in later.
|
||||
type Event struct {
|
||||
// Name of the event (e.g. "signup", "workspace_created").
|
||||
Name string
|
||||
|
||||
// DistinctID identifies the person this event belongs to. For logged-in
|
||||
// users this is user.id; for anonymous events it should be the anon_id
|
||||
// that was previously used on the frontend so identity merging works.
|
||||
DistinctID string
|
||||
|
||||
// WorkspaceID scopes the event to a workspace. Required when the event is
|
||||
// about a workspace-level action (workspace_created, issue_executed, ...).
|
||||
// Empty is allowed for pre-workspace events (signup).
|
||||
WorkspaceID string
|
||||
|
||||
// Properties is the free-form bag of event attributes. Only serialisable
|
||||
// values (string, number, bool, nested maps/slices of the same) should
|
||||
// go here. Never put raw PII like full emails here — use email_domain.
|
||||
Properties map[string]any
|
||||
|
||||
// SetOnce properties attach to the person record and are only written the
|
||||
// first time they appear. Use this for acquisition attribution
|
||||
// (initial_utm_source, etc.) so later events don't overwrite the origin.
|
||||
SetOnce map[string]any
|
||||
|
||||
// Timestamp is optional; when zero the client fills in time.Now().
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// Client is the narrow surface the rest of the codebase depends on. Handlers
|
||||
// call Capture and move on; the implementation is responsible for buffering,
|
||||
// batching, and shipping.
|
||||
type Client interface {
|
||||
Capture(e Event)
|
||||
// Close drains pending events. Call once during graceful shutdown.
|
||||
Close()
|
||||
}
|
||||
|
||||
// NewFromEnv returns a Client configured from environment variables:
|
||||
//
|
||||
// - POSTHOG_API_KEY: project API key. Empty → no-op client.
|
||||
// - POSTHOG_HOST: API host (default https://us.i.posthog.com).
|
||||
// - ANALYTICS_DISABLED: set to "true"/"1" to force a no-op client even
|
||||
// when POSTHOG_API_KEY is set (useful for CI and self-hosted opt-out).
|
||||
func NewFromEnv() Client {
|
||||
if isDisabled() {
|
||||
slog.Info("analytics disabled via ANALYTICS_DISABLED")
|
||||
return NoopClient{}
|
||||
}
|
||||
key := os.Getenv("POSTHOG_API_KEY")
|
||||
if key == "" {
|
||||
slog.Info("analytics: POSTHOG_API_KEY not set, using noop client")
|
||||
return NoopClient{}
|
||||
}
|
||||
host := os.Getenv("POSTHOG_HOST")
|
||||
if host == "" {
|
||||
host = "https://us.i.posthog.com"
|
||||
}
|
||||
slog.Info("analytics: posthog client enabled", "host", host)
|
||||
return NewPostHogClient(PostHogConfig{APIKey: key, Host: host})
|
||||
}
|
||||
|
||||
func isDisabled() bool {
|
||||
v := os.Getenv("ANALYTICS_DISABLED")
|
||||
return v == "true" || v == "1"
|
||||
}
|
||||
|
||||
// NoopClient silently drops all events. Used in tests, in local dev when
|
||||
// POSTHOG_API_KEY is unset, and in self-hosted instances that opt out.
|
||||
type NoopClient struct{}
|
||||
|
||||
func (NoopClient) Capture(Event) {}
|
||||
func (NoopClient) Close() {}
|
||||
@@ -1,119 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNoopClient(t *testing.T) {
|
||||
c := NoopClient{}
|
||||
c.Capture(Event{Name: "foo"})
|
||||
c.Close()
|
||||
}
|
||||
|
||||
func TestPostHogClient_Batching(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
received [][]captureItem
|
||||
)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/batch/" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload capturePayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Errorf("decode payload: %v", err)
|
||||
}
|
||||
if payload.APIKey != "test-key" {
|
||||
t.Errorf("api_key = %q, want test-key", payload.APIKey)
|
||||
}
|
||||
mu.Lock()
|
||||
received = append(received, payload.Batch)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewPostHogClient(PostHogConfig{
|
||||
APIKey: "test-key",
|
||||
Host: srv.URL,
|
||||
BatchSize: 2,
|
||||
FlushEvery: time.Hour, // irrelevant, we hit the size trigger
|
||||
})
|
||||
|
||||
c.Capture(Event{Name: "signup", DistinctID: "u1", WorkspaceID: "w1"})
|
||||
c.Capture(Event{Name: "workspace_created", DistinctID: "u1", WorkspaceID: "w1"})
|
||||
c.Close() // drains
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
total := 0
|
||||
for _, b := range received {
|
||||
total += len(b)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("received %d events, want 2 (batches=%d)", total, len(received))
|
||||
}
|
||||
// Both events should carry workspace_id in properties.
|
||||
for _, batch := range received {
|
||||
for _, item := range batch {
|
||||
if item.Properties["workspace_id"] != "w1" {
|
||||
t.Errorf("missing workspace_id on event %s", item.Event)
|
||||
}
|
||||
if item.DistinctID != "u1" {
|
||||
t.Errorf("distinct_id = %q, want u1", item.DistinctID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostHogClient_DropsWhenFull(t *testing.T) {
|
||||
// Handler blocks so batches never flush — queue will fill up.
|
||||
block := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-block
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer close(block)
|
||||
|
||||
c := NewPostHogClient(PostHogConfig{
|
||||
APIKey: "test-key",
|
||||
Host: srv.URL,
|
||||
QueueSize: 2,
|
||||
BatchSize: 1,
|
||||
FlushEvery: time.Hour,
|
||||
})
|
||||
defer c.Close()
|
||||
|
||||
// First event may be consumed by the worker (which is now blocked in send).
|
||||
// Next events will sit in the queue (cap=2) until it's full and then drop.
|
||||
for i := 0; i < 20; i++ {
|
||||
c.Capture(Event{Name: "spam", DistinctID: "u"})
|
||||
}
|
||||
// Give the worker a chance to pick up at least one.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if c.dropped.Load() == 0 {
|
||||
t.Fatalf("expected some drops when queue saturated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailDomain(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"a@example.com": "example.com",
|
||||
"user@Company.co.uk": "company.co.uk",
|
||||
"": "",
|
||||
"no-at": "",
|
||||
"trailing@": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := emailDomain(in); got != want {
|
||||
t.Errorf("emailDomain(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import "strings"
|
||||
|
||||
// Event names. Keep in sync with docs/analytics.md.
|
||||
const (
|
||||
EventSignup = "signup"
|
||||
EventWorkspaceCreated = "workspace_created"
|
||||
EventRuntimeRegistered = "runtime_registered"
|
||||
EventIssueExecuted = "issue_executed"
|
||||
EventTeamInviteSent = "team_invite_sent"
|
||||
EventTeamInviteAccepted = "team_invite_accepted"
|
||||
)
|
||||
|
||||
// Platform is used as the "platform" event property so funnels can split by
|
||||
// web / desktop / cli. Request-path events use PlatformServer as a fallback
|
||||
// when the caller is a server-originating action (e.g. auto-created user);
|
||||
// otherwise the frontend passes the real platform via a header / body field
|
||||
// in later iterations.
|
||||
const (
|
||||
PlatformServer = "server"
|
||||
PlatformWeb = "web"
|
||||
PlatformDesktop = "desktop"
|
||||
PlatformCLI = "cli"
|
||||
)
|
||||
|
||||
// Signup builds the signup event. signupSource is populated from the
|
||||
// frontend's stored UTM/referrer cookie if present; leave empty otherwise.
|
||||
func Signup(userID, email, signupSource string) Event {
|
||||
return Event{
|
||||
Name: EventSignup,
|
||||
DistinctID: userID,
|
||||
Properties: map[string]any{
|
||||
"email_domain": emailDomain(email),
|
||||
"signup_source": signupSource,
|
||||
},
|
||||
SetOnce: map[string]any{
|
||||
"email": email,
|
||||
"signup_source": signupSource,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WorkspaceCreated builds the workspace_created event. "Is this the user's
|
||||
// first workspace?" is deliberately not stamped here — it's derived in
|
||||
// PostHog by checking whether the user has a prior workspace_created event.
|
||||
func WorkspaceCreated(userID, workspaceID string) Event {
|
||||
return Event{
|
||||
Name: EventWorkspaceCreated,
|
||||
DistinctID: userID,
|
||||
WorkspaceID: workspaceID,
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeRegistered fires on the first time a (workspace, daemon, provider)
|
||||
// triple is upserted. The handler uses a `xmax = 0` flag returned from the
|
||||
// upsert query to distinguish inserts from updates — heartbeats and repeat
|
||||
// registrations never emit this event.
|
||||
//
|
||||
// ownerID may be empty when the daemon authenticates via a daemon token
|
||||
// (no user context); downstream funnels that need per-user attribution
|
||||
// fall back to `workspace_id` as the grouping key.
|
||||
func RuntimeRegistered(ownerID, workspaceID, runtimeID, provider, runtimeVersion, cliVersion string) Event {
|
||||
distinct := ownerID
|
||||
if distinct == "" {
|
||||
// A per-workspace synthetic id keeps PostHog from merging unrelated
|
||||
// daemon registrations across workspaces under a single "anonymous"
|
||||
// person. It's stable within a workspace so repeat heartbeats (which
|
||||
// don't emit anyway) would at least group correctly.
|
||||
distinct = "workspace:" + workspaceID
|
||||
}
|
||||
return Event{
|
||||
Name: EventRuntimeRegistered,
|
||||
DistinctID: distinct,
|
||||
WorkspaceID: workspaceID,
|
||||
Properties: map[string]any{
|
||||
"runtime_id": runtimeID,
|
||||
"provider": provider,
|
||||
"runtime_version": runtimeVersion,
|
||||
"cli_version": cliVersion,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IssueExecuted fires at most once per issue lifetime — on the first task
|
||||
// completion that flips `issues.first_executed_at` from NULL via an atomic
|
||||
// UPDATE. Retries, re-assignments, and comment-triggered follow-ups never
|
||||
// re-emit, which is what keeps the ≥1/≥2/≥5/≥10 funnel buckets honest.
|
||||
//
|
||||
// Deliberately not stamped here: the workspace's Nth-issue ordinal.
|
||||
// Computing it at emit time is not atomic (two concurrent first-completions
|
||||
// both read count=1, both emit n=1), and PostHog derives the same number
|
||||
// exactly at query time from the event stream.
|
||||
func IssueExecuted(actorID, workspaceID, issueID string, taskDurationMS int64) Event {
|
||||
return Event{
|
||||
Name: EventIssueExecuted,
|
||||
DistinctID: actorID,
|
||||
WorkspaceID: workspaceID,
|
||||
Properties: map[string]any{
|
||||
"issue_id": issueID,
|
||||
"task_duration_ms": taskDurationMS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TeamInviteSent fires when a workspace admin creates an invitation.
|
||||
// inviteMethod is "email" for now; future non-email invite flows can pass
|
||||
// their own value to keep this stable.
|
||||
func TeamInviteSent(inviterID, workspaceID, invitedEmail, inviteMethod string) Event {
|
||||
return Event{
|
||||
Name: EventTeamInviteSent,
|
||||
DistinctID: inviterID,
|
||||
WorkspaceID: workspaceID,
|
||||
Properties: map[string]any{
|
||||
"invited_email_domain": emailDomain(invitedEmail),
|
||||
"invite_method": inviteMethod,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TeamInviteAccepted fires when the invitee accepts and joins the workspace.
|
||||
// daysSinceInvite lets us segment fast-acceptance (warm) from long-tail
|
||||
// acceptance (someone dug through old email).
|
||||
func TeamInviteAccepted(inviteeID, workspaceID string, daysSinceInvite int64) Event {
|
||||
return Event{
|
||||
Name: EventTeamInviteAccepted,
|
||||
DistinctID: inviteeID,
|
||||
WorkspaceID: workspaceID,
|
||||
Properties: map[string]any{
|
||||
"days_since_invite": daysSinceInvite,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func emailDomain(email string) string {
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at < 0 || at == len(email)-1 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(email[at+1:])
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultQueueSize = 1024
|
||||
defaultBatchSize = 64
|
||||
defaultFlushEvery = 10 * time.Second
|
||||
defaultFlushTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// PostHogConfig configures the live PostHog client.
|
||||
type PostHogConfig struct {
|
||||
APIKey string
|
||||
Host string
|
||||
|
||||
// Optional overrides. Zero values fall back to sensible defaults.
|
||||
QueueSize int
|
||||
BatchSize int
|
||||
FlushEvery time.Duration
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// PostHogClient ships events to PostHog's /batch/ endpoint. It enqueues events
|
||||
// into a bounded buffer (non-blocking Capture) and flushes them from a
|
||||
// background worker.
|
||||
type PostHogClient struct {
|
||||
cfg PostHogConfig
|
||||
ch chan Event
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
dropped atomic.Uint64 // events dropped because the queue was full
|
||||
sent atomic.Uint64
|
||||
failed atomic.Uint64
|
||||
}
|
||||
|
||||
// NewPostHogClient starts the background flush worker. Caller must call Close
|
||||
// on shutdown to drain pending events.
|
||||
func NewPostHogClient(cfg PostHogConfig) *PostHogClient {
|
||||
if cfg.QueueSize <= 0 {
|
||||
cfg.QueueSize = defaultQueueSize
|
||||
}
|
||||
if cfg.BatchSize <= 0 {
|
||||
cfg.BatchSize = defaultBatchSize
|
||||
}
|
||||
if cfg.FlushEvery <= 0 {
|
||||
cfg.FlushEvery = defaultFlushEvery
|
||||
}
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = &http.Client{Timeout: defaultFlushTimeout}
|
||||
}
|
||||
c := &PostHogClient{
|
||||
cfg: cfg,
|
||||
ch: make(chan Event, cfg.QueueSize),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
c.wg.Add(1)
|
||||
go c.run()
|
||||
return c
|
||||
}
|
||||
|
||||
// Capture enqueues an event. Returns immediately; on a full queue the event
|
||||
// is dropped and counted. Analytics must never block a request handler.
|
||||
func (c *PostHogClient) Capture(e Event) {
|
||||
if e.Timestamp.IsZero() {
|
||||
e.Timestamp = time.Now().UTC()
|
||||
}
|
||||
select {
|
||||
case c.ch <- e:
|
||||
default:
|
||||
n := c.dropped.Add(1)
|
||||
// Log periodically — every 100 drops — so a broken pipe is visible but
|
||||
// doesn't spam logs under sustained load.
|
||||
if n%100 == 1 {
|
||||
slog.Warn("analytics: queue full, dropping event", "event", e.Name, "total_dropped", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops accepting events and drains whatever is already queued.
|
||||
func (c *PostHogClient) Close() {
|
||||
close(c.done)
|
||||
c.wg.Wait()
|
||||
slog.Info("analytics: posthog client closed",
|
||||
"sent", c.sent.Load(),
|
||||
"dropped", c.dropped.Load(),
|
||||
"failed", c.failed.Load(),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *PostHogClient) run() {
|
||||
defer c.wg.Done()
|
||||
ticker := time.NewTicker(c.cfg.FlushEvery)
|
||||
defer ticker.Stop()
|
||||
|
||||
batch := make([]Event, 0, c.cfg.BatchSize)
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
c.send(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case e := <-c.ch:
|
||||
batch = append(batch, e)
|
||||
if len(batch) >= c.cfg.BatchSize {
|
||||
flush()
|
||||
}
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
case <-c.done:
|
||||
// Drain remaining events. The channel is not closed by Close() to
|
||||
// avoid racing with Capture, so we loop until it's empty.
|
||||
for {
|
||||
select {
|
||||
case e := <-c.ch:
|
||||
batch = append(batch, e)
|
||||
if len(batch) >= c.cfg.BatchSize {
|
||||
flush()
|
||||
}
|
||||
default:
|
||||
flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// capturePayload mirrors the PostHog /batch/ JSON shape.
|
||||
type capturePayload struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Batch []captureItem `json:"batch"`
|
||||
}
|
||||
|
||||
type captureItem struct {
|
||||
Event string `json:"event"`
|
||||
DistinctID string `json:"distinct_id"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
func (c *PostHogClient) send(batch []Event) {
|
||||
items := make([]captureItem, 0, len(batch))
|
||||
for _, e := range batch {
|
||||
props := make(map[string]any, len(e.Properties)+2)
|
||||
for k, v := range e.Properties {
|
||||
props[k] = v
|
||||
}
|
||||
if e.WorkspaceID != "" {
|
||||
props["workspace_id"] = e.WorkspaceID
|
||||
}
|
||||
if len(e.SetOnce) > 0 {
|
||||
props["$set_once"] = e.SetOnce
|
||||
}
|
||||
items = append(items, captureItem{
|
||||
Event: e.Name,
|
||||
DistinctID: e.DistinctID,
|
||||
Properties: props,
|
||||
Timestamp: e.Timestamp.UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
body, err := json.Marshal(capturePayload{APIKey: c.cfg.APIKey, Batch: items})
|
||||
if err != nil {
|
||||
c.failed.Add(uint64(len(batch)))
|
||||
slog.Error("analytics: marshal batch", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultFlushTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.Host+"/batch/", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.failed.Add(uint64(len(batch)))
|
||||
slog.Error("analytics: build request", "error", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.cfg.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
c.failed.Add(uint64(len(batch)))
|
||||
slog.Warn("analytics: send batch failed", "error", err, "events", len(batch))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
c.failed.Add(uint64(len(batch)))
|
||||
slog.Warn("analytics: posthog rejected batch", "status", resp.StatusCode, "events", len(batch))
|
||||
return
|
||||
}
|
||||
c.sent.Add(uint64(len(batch)))
|
||||
}
|
||||
@@ -5,61 +5,25 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthCookieName = "multica_auth"
|
||||
CSRFCookieName = "multica_csrf"
|
||||
AuthCookieName = "multica_auth"
|
||||
CSRFCookieName = "multica_csrf"
|
||||
authCookieMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds
|
||||
)
|
||||
|
||||
var ipCookieDomainWarnOnce sync.Once
|
||||
|
||||
// cookieDomain returns the trimmed COOKIE_DOMAIN env value, or "" if it looks
|
||||
// like an IP address. RFC 6265 §4.1.2.3 forbids IP literals in the cookie
|
||||
// Domain attribute, so browsers silently drop Set-Cookie headers that carry
|
||||
// one. An IP value here is almost always a misconfiguration.
|
||||
func cookieDomain() string {
|
||||
raw := strings.TrimSpace(os.Getenv("COOKIE_DOMAIN"))
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// A leading dot ("." for subdomain matching) is legal syntax but doesn't
|
||||
// change whether the remainder is an IP literal.
|
||||
if ip := net.ParseIP(strings.TrimPrefix(raw, ".")); ip != nil {
|
||||
ipCookieDomainWarnOnce.Do(func() {
|
||||
slog.Warn(
|
||||
"COOKIE_DOMAIN looks like an IP address; ignoring. RFC 6265 forbids IP literals in the cookie Domain attribute, so browsers would drop the Set-Cookie. Leave COOKIE_DOMAIN empty for single-host deployments, or use a real domain.",
|
||||
"value", raw,
|
||||
)
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
return strings.TrimSpace(os.Getenv("COOKIE_DOMAIN"))
|
||||
}
|
||||
|
||||
// isSecureCookie reports whether session cookies should carry the Secure flag.
|
||||
// Derived from the scheme of FRONTEND_ORIGIN — browsers silently drop Secure
|
||||
// cookies received on a plain-HTTP page, so the flag has to track the actual
|
||||
// user-facing scheme rather than a coarser environment name.
|
||||
func isSecureCookie() bool {
|
||||
raw := strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(u.Scheme, "https")
|
||||
env := os.Getenv("APP_ENV")
|
||||
return env == "production" || env == "staging"
|
||||
}
|
||||
|
||||
// generateCSRFToken creates a CSRF token bound to the auth token via HMAC.
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsSecureCookie(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
frontendOrigin string
|
||||
want bool
|
||||
}{
|
||||
{"https origin → Secure", "https://app.example.com", true},
|
||||
{"https with port", "https://app.example.com:8443", true},
|
||||
{"http origin → not Secure", "http://192.168.5.5:13000", false},
|
||||
{"http localhost → not Secure", "http://localhost:3000", false},
|
||||
{"empty → not Secure", "", false},
|
||||
{"malformed → not Secure", "::not-a-url", false},
|
||||
{"uppercase scheme still matches", "HTTPS://app.example.com", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("FRONTEND_ORIGIN", tc.frontendOrigin)
|
||||
if got := isSecureCookie(); got != tc.want {
|
||||
t.Errorf("isSecureCookie() = %v, want %v (FRONTEND_ORIGIN=%q)", got, tc.want, tc.frontendOrigin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCookieDomain(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"whitespace only", " ", ""},
|
||||
{"real domain", ".example.com", ".example.com"},
|
||||
{"bare domain", "example.com", "example.com"},
|
||||
{"IPv4 rejected", "192.168.5.5", ""},
|
||||
{"IPv4 with leading dot rejected", ".192.168.5.5", ""},
|
||||
{"IPv6 rejected", "::1", ""},
|
||||
{"IPv6 bracketed is not a valid IP literal → passthrough", "[::1]", "[::1]"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("COOKIE_DOMAIN", tc.env)
|
||||
if got := cookieDomain(); got != tc.want {
|
||||
t.Errorf("cookieDomain() = %q, want %q (COOKIE_DOMAIN=%q)", got, tc.want, tc.env)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetAuthCookies_HTTPSelfHost covers the exact misconfiguration that
|
||||
// shipped to users on LAN self-host: COOKIE_DOMAIN=<ip> + HTTP FRONTEND_ORIGIN.
|
||||
// The cookie must land with no Domain attribute and Secure=false so browsers
|
||||
// actually store it.
|
||||
func TestSetAuthCookies_HTTPSelfHost(t *testing.T) {
|
||||
t.Setenv("FRONTEND_ORIGIN", "http://192.168.5.5:13000")
|
||||
t.Setenv("COOKIE_DOMAIN", "192.168.5.5")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := SetAuthCookies(rec, "test-token"); err != nil {
|
||||
t.Fatalf("SetAuthCookies: %v", err)
|
||||
}
|
||||
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 2 {
|
||||
t.Fatalf("expected 2 cookies (auth + csrf), got %d", len(cookies))
|
||||
}
|
||||
for _, c := range cookies {
|
||||
if c.Secure {
|
||||
t.Errorf("cookie %q has Secure=true on HTTP origin; browser would reject it", c.Name)
|
||||
}
|
||||
if c.Domain != "" {
|
||||
t.Errorf("cookie %q has Domain=%q; IP-address Domain would be rejected by the browser (RFC 6265)", c.Name, c.Domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAuthCookies_HTTPSProduction(t *testing.T) {
|
||||
t.Setenv("FRONTEND_ORIGIN", "https://app.example.com")
|
||||
t.Setenv("COOKIE_DOMAIN", "app.example.com")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := SetAuthCookies(rec, "test-token"); err != nil {
|
||||
t.Fatalf("SetAuthCookies: %v", err)
|
||||
}
|
||||
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if !c.Secure {
|
||||
t.Errorf("cookie %q missing Secure flag on HTTPS origin", c.Name)
|
||||
}
|
||||
if c.Domain != "app.example.com" {
|
||||
t.Errorf("cookie %q Domain = %q, want %q", c.Name, c.Domain, "app.example.com")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,79 +19,8 @@ import (
|
||||
|
||||
// GitHubRelease is the subset of the GitHub releases API response we need.
|
||||
type GitHubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Assets []GitHubReleaseAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type GitHubReleaseAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
func releaseArchiveExtension(goos string) string {
|
||||
if goos == "windows" {
|
||||
return "zip"
|
||||
}
|
||||
return "tar.gz"
|
||||
}
|
||||
|
||||
func normalizeReleaseTag(targetVersion string) string {
|
||||
tag := strings.TrimSpace(targetVersion)
|
||||
if !strings.HasPrefix(tag, "v") {
|
||||
tag = "v" + tag
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
func releaseAssetCandidates(targetVersion, goos, goarch string) []string {
|
||||
tag := normalizeReleaseTag(targetVersion)
|
||||
version := strings.TrimPrefix(tag, "v")
|
||||
ext := releaseArchiveExtension(goos)
|
||||
// Prefer the versioned name (current scheme); fall back to the legacy
|
||||
// `multica_{os}_{arch}` name for releases that still ship it.
|
||||
return []string{
|
||||
fmt.Sprintf("multica-cli-%s-%s-%s.%s", version, goos, goarch, ext),
|
||||
fmt.Sprintf("multica_%s_%s.%s", goos, goarch, ext),
|
||||
}
|
||||
}
|
||||
|
||||
func findReleaseAsset(assets []GitHubReleaseAsset, targetVersion, goos, goarch string) (*GitHubReleaseAsset, error) {
|
||||
for _, candidate := range releaseAssetCandidates(targetVersion, goos, goarch) {
|
||||
for i := range assets {
|
||||
if assets[i].Name == candidate {
|
||||
return &assets[i], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates := strings.Join(releaseAssetCandidates(targetVersion, goos, goarch), ", ")
|
||||
return nil, fmt.Errorf("no matching release asset for %s/%s (tried: %s)", goos, goarch, candidates)
|
||||
}
|
||||
|
||||
func fetchReleaseByTag(tag string) (*GitHubRelease, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/multica-ai/multica/releases/tags/"+tag, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release GitHubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &release, nil
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// FetchLatestRelease fetches the latest release tag from the multica GitHub repo.
|
||||
@@ -177,17 +106,18 @@ func UpdateViaDownload(targetVersion string) (string, error) {
|
||||
return "", fmt.Errorf("resolve symlink: %w", err)
|
||||
}
|
||||
|
||||
tag := normalizeReleaseTag(targetVersion)
|
||||
release, err := fetchReleaseByTag(tag)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch release metadata: %w", err)
|
||||
// Build download URL: multica_{os}_{arch}.{tar.gz|zip}
|
||||
// GoReleaser produces .zip for Windows and .tar.gz for everything else.
|
||||
tag := targetVersion
|
||||
if !strings.HasPrefix(tag, "v") {
|
||||
tag = "v" + tag
|
||||
}
|
||||
asset, err := findReleaseAsset(release.Assets, tag, runtime.GOOS, runtime.GOARCH)
|
||||
if err != nil {
|
||||
return "", err
|
||||
ext := "tar.gz"
|
||||
if runtime.GOOS == "windows" {
|
||||
ext = "zip"
|
||||
}
|
||||
downloadURL := asset.BrowserDownloadURL
|
||||
assetName := asset.Name
|
||||
assetName := fmt.Sprintf("multica_%s_%s.%s", runtime.GOOS, runtime.GOARCH, ext)
|
||||
downloadURL := fmt.Sprintf("https://github.com/multica-ai/multica/releases/download/%s/%s", tag, assetName)
|
||||
|
||||
// Download the archive.
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
@@ -242,9 +172,8 @@ func UpdateViaDownload(targetVersion string) (string, error) {
|
||||
return "", fmt.Errorf("chmod temp file: %w", err)
|
||||
}
|
||||
|
||||
// Replace the original binary. On Windows this moves the running executable
|
||||
// aside first; on Unix a plain rename over the running inode is fine.
|
||||
if err := replaceBinary(tmpPath, exePath); err != nil {
|
||||
// Replace the original binary.
|
||||
if err := os.Rename(tmpPath, exePath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("replace binary: %w", err)
|
||||
}
|
||||
@@ -312,3 +241,4 @@ func extractBinaryFromZip(r io.Reader, name string) ([]byte, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("binary %q not found in archive", name)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReleaseAssetCandidates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
targetVersion string
|
||||
goos string
|
||||
goarch string
|
||||
wantAssets []string
|
||||
}{
|
||||
{
|
||||
name: "darwin prefers versioned then legacy candidate",
|
||||
targetVersion: "v1.2.3",
|
||||
goos: "darwin",
|
||||
goarch: "arm64",
|
||||
wantAssets: []string{
|
||||
"multica-cli-1.2.3-darwin-arm64.tar.gz",
|
||||
"multica_darwin_arm64.tar.gz",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux normalizes missing v in versioned candidate",
|
||||
targetVersion: "1.2.3",
|
||||
goos: "linux",
|
||||
goarch: "amd64",
|
||||
wantAssets: []string{
|
||||
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
||||
"multica_linux_amd64.tar.gz",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "windows uses zip assets",
|
||||
targetVersion: "1.2.3",
|
||||
goos: "windows",
|
||||
goarch: "amd64",
|
||||
wantAssets: []string{
|
||||
"multica-cli-1.2.3-windows-amd64.zip",
|
||||
"multica_windows_amd64.zip",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := releaseAssetCandidates(tt.targetVersion, tt.goos, tt.goarch)
|
||||
if len(got) != len(tt.wantAssets) {
|
||||
t.Fatalf("candidate count mismatch: got %d, want %d", len(got), len(tt.wantAssets))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.wantAssets[i] {
|
||||
t.Fatalf("candidate[%d] mismatch: got %q, want %q", i, got[i], tt.wantAssets[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindReleaseAsset(t *testing.T) {
|
||||
t.Run("prefers versioned asset when both names exist", func(t *testing.T) {
|
||||
assets := []GitHubReleaseAsset{
|
||||
{Name: "multica_darwin_amd64.tar.gz", BrowserDownloadURL: "old"},
|
||||
{Name: "multica-cli-1.2.3-darwin-amd64.tar.gz", BrowserDownloadURL: "new"},
|
||||
}
|
||||
|
||||
got, err := findReleaseAsset(assets, "v1.2.3", "darwin", "amd64")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Name != "multica-cli-1.2.3-darwin-amd64.tar.gz" {
|
||||
t.Fatalf("asset mismatch: got %q", got.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to legacy asset when versioned is absent", func(t *testing.T) {
|
||||
assets := []GitHubReleaseAsset{
|
||||
{Name: "multica_linux_amd64.tar.gz", BrowserDownloadURL: "old"},
|
||||
}
|
||||
|
||||
got, err := findReleaseAsset(assets, "1.2.3", "linux", "amd64")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Name != "multica_linux_amd64.tar.gz" {
|
||||
t.Fatalf("asset mismatch: got %q", got.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error when no candidate matches", func(t *testing.T) {
|
||||
_, err := findReleaseAsset([]GitHubReleaseAsset{{Name: "checksums.txt"}}, "1.2.3", "linux", "amd64")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import "os"
|
||||
|
||||
// replaceBinary swaps the running executable for the freshly-downloaded one.
|
||||
// On Unix, the kernel keeps the old inode alive for the running process, so a
|
||||
// plain rename is safe.
|
||||
func replaceBinary(tmpPath, exePath string) error {
|
||||
return os.Rename(tmpPath, exePath)
|
||||
}
|
||||
|
||||
// CleanupStaleUpdateArtifacts is a no-op on Unix — there are no sidecar files
|
||||
// to reclaim.
|
||||
func CleanupStaleUpdateArtifacts() {}
|
||||
@@ -1,60 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// oldBinarySuffix is appended to the previous executable while a new one is
|
||||
// being installed. Windows refuses to overwrite a running .exe but allows
|
||||
// renaming it, so we shuffle the running binary out of the way first.
|
||||
const oldBinarySuffix = ".old"
|
||||
|
||||
// replaceBinary swaps the running executable for the freshly-downloaded one.
|
||||
// Windows holds an exclusive handle on a running .exe, so the rename-over
|
||||
// pattern used on Unix fails with "Access is denied". Instead:
|
||||
// 1. Clear any stale leftover from a previous update.
|
||||
// 2. Move the running executable aside to exePath+".old".
|
||||
// 3. Rename the new binary into place.
|
||||
// 4. If step 3 fails, restore the original so the user isn't stranded.
|
||||
//
|
||||
// The leftover .old file is cleaned up on next startup via
|
||||
// CleanupStaleUpdateArtifacts.
|
||||
func replaceBinary(tmpPath, exePath string) error {
|
||||
oldPath := exePath + oldBinarySuffix
|
||||
|
||||
// Best-effort cleanup; if this fails (file still locked) the next Rename
|
||||
// will surface a useful error.
|
||||
_ = os.Remove(oldPath)
|
||||
|
||||
if err := os.Rename(exePath, oldPath); err != nil {
|
||||
return fmt.Errorf("move running binary aside: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, exePath); err != nil {
|
||||
// Restore so the user isn't left without a multica.exe.
|
||||
if rerr := os.Rename(oldPath, exePath); rerr != nil {
|
||||
return fmt.Errorf("install new binary: %w (and failed to restore: %v)", err, rerr)
|
||||
}
|
||||
return fmt.Errorf("install new binary: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupStaleUpdateArtifacts removes leftover `.old` binaries from previous
|
||||
// updates. Windows can't delete a running .exe, so a prior update may have
|
||||
// left one behind; once the user restarts, this call reclaims the space.
|
||||
func CleanupStaleUpdateArtifacts() {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exePath); err == nil {
|
||||
exePath = resolved
|
||||
}
|
||||
_ = os.Remove(exePath + oldBinarySuffix)
|
||||
}
|
||||
@@ -122,15 +122,10 @@ func (c *Client) ReportTaskUsage(ctx context.Context, taskID string, usage []Tas
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) FailTask(ctx context.Context, taskID, errMsg, sessionID, workDir string) error {
|
||||
body := map[string]any{"error": errMsg}
|
||||
if sessionID != "" {
|
||||
body["session_id"] = sessionID
|
||||
}
|
||||
if workDir != "" {
|
||||
body["work_dir"] = workDir
|
||||
}
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/fail", taskID), body, nil)
|
||||
func (c *Client) FailTask(ctx context.Context, taskID, errMsg string) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/fail", taskID), map[string]any{
|
||||
"error": errMsg,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// GetTaskStatus returns the current status of a task. Used by the daemon to
|
||||
@@ -147,10 +142,9 @@ func (c *Client) GetTaskStatus(ctx context.Context, taskID string) (string, erro
|
||||
|
||||
// HeartbeatResponse contains the server's response to a heartbeat, including any pending actions.
|
||||
type HeartbeatResponse struct {
|
||||
Status string `json:"status"`
|
||||
PendingPing *PendingPing `json:"pending_ping,omitempty"`
|
||||
PendingUpdate *PendingUpdate `json:"pending_update,omitempty"`
|
||||
PendingModelList *PendingModelList `json:"pending_model_list,omitempty"`
|
||||
Status string `json:"status"`
|
||||
PendingPing *PendingPing `json:"pending_ping,omitempty"`
|
||||
PendingUpdate *PendingUpdate `json:"pending_update,omitempty"`
|
||||
}
|
||||
|
||||
// PendingPing represents a ping test request from the server.
|
||||
@@ -164,11 +158,6 @@ type PendingUpdate struct {
|
||||
TargetVersion string `json:"target_version"`
|
||||
}
|
||||
|
||||
// PendingModelList represents a request to enumerate supported models.
|
||||
type PendingModelList struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (c *Client) SendHeartbeat(ctx context.Context, runtimeID string) (*HeartbeatResponse, error) {
|
||||
var resp HeartbeatResponse
|
||||
if err := c.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{
|
||||
@@ -188,11 +177,6 @@ func (c *Client) ReportUpdateResult(ctx context.Context, runtimeID, updateID str
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/update/%s/result", runtimeID, updateID), result, nil)
|
||||
}
|
||||
|
||||
// ReportModelListResult sends the model-discovery result back to the server.
|
||||
func (c *Client) ReportModelListResult(ctx context.Context, runtimeID, requestID string, result map[string]any) error {
|
||||
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/models/%s/result", runtimeID, requestID), result, nil)
|
||||
}
|
||||
|
||||
// WorkspaceInfo holds minimal workspace metadata returned by the API.
|
||||
type WorkspaceInfo struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -34,7 +34,7 @@ type Config struct {
|
||||
CLIVersion string // multica CLI version (e.g. "0.1.13")
|
||||
LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
|
||||
Profile string // profile name (empty = default)
|
||||
Agents map[string]AgentEntry // keyed by provider: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi
|
||||
Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini, pi
|
||||
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
|
||||
KeepEnvAfterTask bool // preserve env after task for debugging
|
||||
HealthPort int // local HTTP port for health checks (default: 19514)
|
||||
@@ -142,15 +142,8 @@ func LoadConfig(overrides Overrides) (Config, error) {
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_COPILOT_MODEL")),
|
||||
}
|
||||
}
|
||||
kimiPath := envOrDefault("MULTICA_KIMI_PATH", "kimi")
|
||||
if _, err := exec.LookPath(kimiPath); err == nil {
|
||||
agents["kimi"] = AgentEntry{
|
||||
Path: kimiPath,
|
||||
Model: strings.TrimSpace(os.Getenv("MULTICA_KIMI_MODEL")),
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, or kimi and ensure it is on PATH")
|
||||
return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, or cursor-agent and ensure it is on PATH")
|
||||
}
|
||||
|
||||
// Host info
|
||||
|
||||
@@ -496,70 +496,11 @@ func (d *Daemon) heartbeatLoop(ctx context.Context) {
|
||||
if resp.PendingUpdate != nil {
|
||||
go d.handleUpdate(ctx, rid, resp.PendingUpdate)
|
||||
}
|
||||
|
||||
// Handle pending model-list requests.
|
||||
if resp.PendingModelList != nil {
|
||||
rt := d.findRuntime(rid)
|
||||
if rt != nil {
|
||||
go d.handleModelList(ctx, *rt, resp.PendingModelList.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleModelList resolves the provider's supported models (via static
|
||||
// catalog or by shelling out to the agent CLI) and reports the result
|
||||
// back to the server. Model discovery failures are reported as empty
|
||||
// lists rather than errors so the UI can still render a creatable
|
||||
// dropdown.
|
||||
func (d *Daemon) handleModelList(ctx context.Context, rt Runtime, requestID string) {
|
||||
d.logger.Info("model list requested", "runtime_id", rt.ID, "request_id", requestID, "provider", rt.Provider)
|
||||
|
||||
entry, ok := d.cfg.Agents[rt.Provider]
|
||||
if !ok {
|
||||
d.client.ReportModelListResult(ctx, rt.ID, requestID, map[string]any{
|
||||
"status": "failed",
|
||||
"error": fmt.Sprintf("no agent configured for provider %q", rt.Provider),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
models, err := agent.ListModels(ctx, rt.Provider, entry.Path)
|
||||
if err != nil {
|
||||
d.client.ReportModelListResult(ctx, rt.ID, requestID, map[string]any{
|
||||
"status": "failed",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Wire format matches handler.ModelEntry. Use a struct (not
|
||||
// map[string]string) so the Default bool round-trips — without
|
||||
// it the UI loses its "default" badge on the advertised pick.
|
||||
type modelWire struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
}
|
||||
wire := make([]modelWire, 0, len(models))
|
||||
for _, m := range models {
|
||||
wire = append(wire, modelWire{
|
||||
ID: m.ID,
|
||||
Label: m.Label,
|
||||
Provider: m.Provider,
|
||||
Default: m.Default,
|
||||
})
|
||||
}
|
||||
d.client.ReportModelListResult(ctx, rt.ID, requestID, map[string]any{
|
||||
"status": "completed",
|
||||
"models": wire,
|
||||
"supported": agent.ModelSelectionSupported(rt.Provider),
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Daemon) handlePing(ctx context.Context, rt Runtime, pingID string) {
|
||||
d.logger.Info("ping requested", "runtime_id", rt.ID, "ping_id", pingID, "provider", rt.Provider)
|
||||
|
||||
@@ -849,7 +790,7 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
||||
|
||||
if err := d.client.StartTask(ctx, task.ID); err != nil {
|
||||
taskLog.Error("start task failed", "error", err)
|
||||
if failErr := d.client.FailTask(ctx, task.ID, fmt.Sprintf("start task failed: %s", err.Error()), "", ""); failErr != nil {
|
||||
if failErr := d.client.FailTask(ctx, task.ID, fmt.Sprintf("start task failed: %s", err.Error())); failErr != nil {
|
||||
taskLog.Error("fail task after start error", "error", failErr)
|
||||
}
|
||||
return
|
||||
@@ -894,9 +835,7 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
||||
|
||||
if err != nil {
|
||||
taskLog.Error("task failed", "error", err)
|
||||
// runTask returned without a TaskResult, so we don't have a SessionID
|
||||
// to forward — best we can do is record the failure.
|
||||
if failErr := d.client.FailTask(ctx, task.ID, err.Error(), "", ""); failErr != nil {
|
||||
if failErr := d.client.FailTask(ctx, task.ID, err.Error()); failErr != nil {
|
||||
taskLog.Error("fail task callback failed", "error", failErr)
|
||||
}
|
||||
return
|
||||
@@ -921,18 +860,14 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
||||
|
||||
switch result.Status {
|
||||
case "blocked":
|
||||
// Forward SessionID/WorkDir even on the blocked path: the agent may
|
||||
// have built a real session before getting stuck (rate-limit, tool
|
||||
// error, etc.) and we want the next chat turn to resume there
|
||||
// rather than start over and "forget" the conversation.
|
||||
if err := d.client.FailTask(ctx, task.ID, result.Comment, result.SessionID, result.WorkDir); err != nil {
|
||||
if err := d.client.FailTask(ctx, task.ID, result.Comment); err != nil {
|
||||
taskLog.Error("report blocked task failed", "error", err)
|
||||
}
|
||||
default:
|
||||
taskLog.Info("task completed", "status", result.Status)
|
||||
if err := d.client.CompleteTask(ctx, task.ID, result.Comment, result.BranchName, result.SessionID, result.WorkDir); err != nil {
|
||||
taskLog.Error("complete task failed, falling back to fail", "error", err)
|
||||
if failErr := d.client.FailTask(ctx, task.ID, fmt.Sprintf("complete task failed: %s", err.Error()), result.SessionID, result.WorkDir); failErr != nil {
|
||||
if failErr := d.client.FailTask(ctx, task.ID, fmt.Sprintf("complete task failed: %s", err.Error())); failErr != nil {
|
||||
taskLog.Error("fail task fallback also failed", "error", failErr)
|
||||
}
|
||||
}
|
||||
@@ -949,15 +884,6 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
||||
}
|
||||
|
||||
func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLog *slog.Logger) (TaskResult, error) {
|
||||
// Refuse to spawn an agent without a workspace. An empty workspace_id
|
||||
// here would make MULTICA_WORKSPACE_ID empty in the agent env, and the
|
||||
// CLI would otherwise silently fall back to the user-global config — a
|
||||
// path that can leak operations into an unrelated workspace when
|
||||
// multiple workspaces share a host.
|
||||
if task.WorkspaceID == "" {
|
||||
return TaskResult{}, fmt.Errorf("refusing to spawn agent: task has no workspace_id (task_id=%s)", task.ID)
|
||||
}
|
||||
|
||||
entry, ok := d.cfg.Agents[provider]
|
||||
if !ok {
|
||||
return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider)
|
||||
@@ -1086,39 +1012,14 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
|
||||
customArgs = task.Agent.CustomArgs
|
||||
mcpConfig = task.Agent.McpConfig
|
||||
}
|
||||
// Two-tier model resolution: an explicit agent.model wins,
|
||||
// then the daemon-wide MULTICA_<PROVIDER>_MODEL env var. If
|
||||
// both are empty we deliberately pass "" through — each
|
||||
// backend omits `--model` from the CLI invocation, so the
|
||||
// provider picks its own default (Claude Code's shipped
|
||||
// default, codex app-server's account-scoped default, etc.).
|
||||
// Baking a Go-side "recommended default" here is how the
|
||||
// cursor regression happened — static guesses drift from
|
||||
// whatever the upstream CLI actually accepts.
|
||||
model := ""
|
||||
if task.Agent != nil && task.Agent.Model != "" {
|
||||
model = task.Agent.Model
|
||||
}
|
||||
if model == "" {
|
||||
model = entry.Model
|
||||
}
|
||||
execOpts := agent.ExecOptions{
|
||||
Cwd: env.WorkDir,
|
||||
Model: model,
|
||||
Model: entry.Model,
|
||||
Timeout: d.cfg.AgentTimeout,
|
||||
ResumeSessionID: task.PriorSessionID,
|
||||
CustomArgs: customArgs,
|
||||
McpConfig: mcpConfig,
|
||||
}
|
||||
// openclaw loads its bootstrap files (AGENTS.md, SOUL.md, ...) from its own
|
||||
// workspace dir rather than the task workdir, so the AGENTS.md written by
|
||||
// execenv.InjectRuntimeConfig is never read. Pass agent instructions inline
|
||||
// via SystemPrompt so the backend can prepend them to the --message payload.
|
||||
// Other providers already surface instructions through their runtime config
|
||||
// file and don't need this.
|
||||
if provider == "openclaw" {
|
||||
execOpts.SystemPrompt = instructions
|
||||
}
|
||||
|
||||
result, tools, err := d.executeAndDrain(ctx, backend, prompt, execOpts, taskLog, task.ID)
|
||||
if err != nil {
|
||||
@@ -1168,17 +1069,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
|
||||
switch result.Status {
|
||||
case "completed":
|
||||
if result.Output == "" {
|
||||
// Even an empty-output completion may have established a real
|
||||
// session — surface it through the blocked path so the next chat
|
||||
// turn can still resume from where this one left off.
|
||||
return TaskResult{
|
||||
Status: "blocked",
|
||||
Comment: fmt.Sprintf("%s returned empty output", provider),
|
||||
SessionID: result.SessionID,
|
||||
WorkDir: env.WorkDir,
|
||||
EnvRoot: env.RootDir,
|
||||
Usage: usageEntries,
|
||||
}, nil
|
||||
return TaskResult{}, fmt.Errorf("%s returned empty output", provider)
|
||||
}
|
||||
return TaskResult{
|
||||
Status: "completed",
|
||||
@@ -1189,36 +1080,13 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
|
||||
Usage: usageEntries,
|
||||
}, nil
|
||||
case "timeout":
|
||||
// Surface session_id/work_dir so the chat resume pointer is kept
|
||||
// in sync even when the agent times out after building a session.
|
||||
// We mark as "blocked" (not a hard error return) so handleTask
|
||||
// goes through the FailTask path that forwards session info.
|
||||
return TaskResult{
|
||||
Status: "blocked",
|
||||
Comment: fmt.Sprintf("%s timed out after %s", provider, d.cfg.AgentTimeout),
|
||||
SessionID: result.SessionID,
|
||||
WorkDir: env.WorkDir,
|
||||
EnvRoot: env.RootDir,
|
||||
Usage: usageEntries,
|
||||
}, nil
|
||||
return TaskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout)
|
||||
default:
|
||||
errMsg := result.Error
|
||||
if errMsg == "" {
|
||||
errMsg = fmt.Sprintf("%s execution %s", provider, result.Status)
|
||||
}
|
||||
// Forward SessionID/WorkDir on the blocked path: backends commonly
|
||||
// emit a real session_id before failing (rate-limit, tool error,
|
||||
// model reject, …). Without this the chat_session resume pointer
|
||||
// would either be left stale or overwritten with NULL on the
|
||||
// server, causing the next chat turn to lose context.
|
||||
return TaskResult{
|
||||
Status: "blocked",
|
||||
Comment: errMsg,
|
||||
SessionID: result.SessionID,
|
||||
WorkDir: env.WorkDir,
|
||||
EnvRoot: env.RootDir,
|
||||
Usage: usageEntries,
|
||||
}, nil
|
||||
return TaskResult{Status: "blocked", Comment: errMsg, EnvRoot: env.RootDir, Usage: usageEntries}, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,20 +112,14 @@ func TestBuildPromptCommentTriggered(t *testing.T) {
|
||||
Agent: &AgentData{Name: "Test"},
|
||||
})
|
||||
|
||||
// Prompt should contain the comment content, the trigger comment id, and
|
||||
// the full reply command with --parent. Re-emitting --parent on every turn
|
||||
// is what prevents resumed sessions from reusing the previous turn's
|
||||
// --parent UUID.
|
||||
// Prompt should contain the comment content directly.
|
||||
for _, want := range []string{
|
||||
issueID,
|
||||
commentContent,
|
||||
"comment that triggered this task",
|
||||
commentID,
|
||||
"multica issue comment add " + issueID + " --parent " + commentID,
|
||||
"do NOT reuse --parent values from previous turns",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt missing %q\n---\n%s", want, prompt)
|
||||
t.Fatalf("prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
|
||||
// Pi: skills → {workDir}/.pi/agent/skills/{name}/SKILL.md (native discovery)
|
||||
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
|
||||
// Kimi: skills → {workDir}/.kimi/skills/{name}/SKILL.md (native discovery)
|
||||
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
|
||||
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
contextDir := filepath.Join(workDir, ".agent_context")
|
||||
@@ -70,10 +69,6 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
|
||||
case "cursor":
|
||||
// Cursor natively discovers skills from .cursor/skills/ in the workdir.
|
||||
skillsDir = filepath.Join(workDir, ".cursor", "skills")
|
||||
case "kimi":
|
||||
// Kimi Code CLI auto-discovers project-level skills from .kimi/skills/
|
||||
// in the workdir. See https://moonshotai.github.io/kimi-cli/en/customization/skills.html
|
||||
skillsDir = filepath.Join(workDir, ".kimi", "skills")
|
||||
default:
|
||||
// Fallback: write to .agent_context/skills/ (referenced by meta config).
|
||||
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
|
||||
|
||||
@@ -671,66 +671,6 @@ func TestPrepareWithRepoContextOpencode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectRuntimeConfigRequiresExplicitCommentPost ensures the injected
|
||||
// workflow makes "post a comment with results" an explicit, unmissable step in
|
||||
// both the assignment- and comment-triggered branches, plus hard-warns in the
|
||||
// Output section that terminal/log text is not user-visible. Agents were
|
||||
// silently finishing tasks without ever posting their result to the issue; see
|
||||
// MUL-1124. Covering this in a test prevents the guidance from decaying back
|
||||
// into a nested clause again.
|
||||
func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assignmentCtx := TaskContextForEnv{IssueID: "issue-1"}
|
||||
commentCtx := TaskContextForEnv{IssueID: "issue-1", TriggerCommentID: "comment-1"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ctx TaskContextForEnv
|
||||
}{
|
||||
{"assignment-triggered", assignmentCtx},
|
||||
{"comment-triggered", commentCtx},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := InjectRuntimeConfig(dir, "claude", tc.ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read CLAUDE.md: %v", err)
|
||||
}
|
||||
s := string(data)
|
||||
|
||||
// The workflow must contain an explicit `multica issue comment add`
|
||||
// invocation for this issue — not just a prose mention of posting.
|
||||
mustContain := []string{
|
||||
"multica issue comment add issue-1",
|
||||
"mandatory",
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("%s: CLAUDE.md missing %q\n---\n%s", tc.name, want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// The Output section must carry a hard warning that terminal/log
|
||||
// output is not user-visible. This is the second line of defense
|
||||
// in case the agent skips past the workflow steps.
|
||||
for _, want := range []string{
|
||||
"Final results MUST be delivered via `multica issue comment add`",
|
||||
"does NOT see your terminal output",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("%s: Output warning missing %q", tc.name, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
@@ -1116,11 +1056,11 @@ network_access = true
|
||||
func TestCodexSandboxPolicyFor(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
goos string
|
||||
version string
|
||||
wantMode string
|
||||
wantNet bool
|
||||
name string
|
||||
goos string
|
||||
version string
|
||||
wantMode string
|
||||
wantNet bool
|
||||
}{
|
||||
{"linux any version", "linux", "0.100.0", "workspace-write", true},
|
||||
{"linux unknown version", "linux", "", "workspace-write", true},
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package execenv
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuildCommentReplyInstructions returns the canonical block telling an agent
|
||||
// how to post its reply for a comment-triggered task. Both the per-turn
|
||||
// prompt (daemon.buildCommentPrompt) and the CLAUDE.md workflow
|
||||
// (InjectRuntimeConfig) call this so the trigger comment ID and the
|
||||
// --parent value cannot drift between surfaces.
|
||||
//
|
||||
// The explicit "do not reuse --parent from previous turns" wording exists
|
||||
// because resumed Claude sessions keep prior turns' tool calls in context
|
||||
// and will otherwise copy the old --parent UUID forward.
|
||||
func BuildCommentReplyInstructions(issueID, triggerCommentID string) string {
|
||||
if triggerCommentID == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Reply by running exactly this command — always use the trigger comment ID below, "+
|
||||
"do NOT reuse --parent values from previous turns in this session:\n\n"+
|
||||
" multica issue comment add %s --parent %s --content \"...\"\n",
|
||||
issueID, triggerCommentID,
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package execenv
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildCommentReplyInstructionsIncludesTriggerID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
issueID := "11111111-1111-1111-1111-111111111111"
|
||||
triggerID := "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
got := BuildCommentReplyInstructions(issueID, triggerID)
|
||||
|
||||
for _, want := range []string{
|
||||
"multica issue comment add " + issueID + " --parent " + triggerID,
|
||||
"do NOT reuse --parent values from previous turns",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("reply instructions missing %q\n---\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommentReplyInstructionsEmptyWhenNoTrigger(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := BuildCommentReplyInstructions("issue-id", ""); got != "" {
|
||||
t.Fatalf("expected empty string when triggerCommentID is empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigCommentTriggerUsesHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
issueID := "11111111-1111-1111-1111-111111111111"
|
||||
triggerID := "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueID: issueID,
|
||||
TriggerCommentID: triggerID,
|
||||
}
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read CLAUDE.md: %v", err)
|
||||
}
|
||||
|
||||
s := string(content)
|
||||
for _, want := range []string{
|
||||
triggerID,
|
||||
"multica issue comment add " + issueID + " --parent " + triggerID,
|
||||
"do NOT reuse --parent values from previous turns",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("CLAUDE.md missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,13 @@ import (
|
||||
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
|
||||
// For Pi: writes {workDir}/AGENTS.md (skills discovered natively from ~/.pi/agent/skills/)
|
||||
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
|
||||
// For Kimi: writes {workDir}/AGENTS.md (Kimi Code CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
content := buildMetaSkillContent(provider, ctx)
|
||||
|
||||
switch provider {
|
||||
case "claude":
|
||||
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
case "codex", "copilot", "opencode", "openclaw", "pi", "cursor", "kimi":
|
||||
case "codex", "copilot", "opencode", "openclaw", "pi", "cursor":
|
||||
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
case "gemini":
|
||||
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
|
||||
@@ -130,9 +129,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation\n", ctx.IssueID)
|
||||
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
|
||||
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked — do NOT confuse it with previous comments\n", ctx.TriggerCommentID)
|
||||
b.WriteString("4. If the comment requests code changes or further work, do the work first\n")
|
||||
b.WriteString("5. **Post your reply as a comment — this step is mandatory.** Text in your terminal or run logs is NOT delivered to the user. ")
|
||||
b.WriteString(BuildCommentReplyInstructions(ctx.IssueID, ctx.TriggerCommentID))
|
||||
fmt.Fprintf(&b, "4. Reply: `multica issue comment add %s --parent %s --content \"...\"`\n", ctx.IssueID, ctx.TriggerCommentID)
|
||||
b.WriteString("5. If the comment requests code changes or further work, do the work first, then reply with your results\n")
|
||||
b.WriteString("6. Do NOT change the issue status unless the comment explicitly asks for it\n\n")
|
||||
} else {
|
||||
// Assignment-triggered: defer to agent Skills for workflow specifics.
|
||||
@@ -140,10 +138,10 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand your task\n", ctx.IssueID)
|
||||
fmt.Fprintf(&b, "2. Run `multica issue status %s in_progress`\n", ctx.IssueID)
|
||||
b.WriteString("3. Read comments for additional context or human instructions\n")
|
||||
b.WriteString("4. Follow your Skills and Agent Identity to complete the task (write code, investigate, etc.)\n")
|
||||
fmt.Fprintf(&b, "5. **Post your final results as a comment — this step is mandatory**: `multica issue comment add %s --content \"...\"`. Your results are only visible to the user if posted via this CLI call; text in your terminal or run logs is NOT delivered.\n", ctx.IssueID)
|
||||
fmt.Fprintf(&b, "6. When done, run `multica issue status %s in_review`\n", ctx.IssueID)
|
||||
fmt.Fprintf(&b, "7. If blocked, run `multica issue status %s blocked` and post a comment explaining why\n\n", ctx.IssueID)
|
||||
b.WriteString("4. Follow your Skills and Agent Identity to determine how to complete this task.\n")
|
||||
b.WriteString(" If no relevant skill applies, the default workflow is: understand the task → do the work → post a comment with results → update issue status.\n")
|
||||
fmt.Fprintf(&b, "5. When done, run `multica issue status %s in_review`\n", ctx.IssueID)
|
||||
fmt.Fprintf(&b, "6. If blocked, run `multica issue status %s blocked` and post a comment explaining why\n\n", ctx.IssueID)
|
||||
}
|
||||
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
@@ -152,8 +150,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
case "claude":
|
||||
// Claude discovers skills natively from .claude/skills/ — just list names.
|
||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||
case "codex", "copilot", "opencode", "openclaw", "pi", "cursor", "kimi":
|
||||
// Codex, Copilot, OpenCode, OpenClaw, Pi, Cursor, and Kimi discover skills natively from their respective paths — just list names.
|
||||
case "codex", "copilot", "opencode", "openclaw", "pi", "cursor":
|
||||
// Codex, Copilot, OpenCode, OpenClaw, Pi, and Cursor discover skills natively from their respective paths — just list names.
|
||||
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
|
||||
case "gemini":
|
||||
// Gemini reads GEMINI.md directly; point it at the fallback skills dir.
|
||||
@@ -191,7 +189,6 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString("do NOT attempt to work around it. Instead, post a comment mentioning the workspace owner to request the missing functionality.\n\n")
|
||||
|
||||
b.WriteString("## Output\n\n")
|
||||
b.WriteString("⚠️ **Final results MUST be delivered via `multica issue comment add`.** The user does NOT see your terminal output, assistant chat text, or run logs — only comments on the issue. A task that finishes without a result comment is invisible to the user, even if the work itself was correct.\n\n")
|
||||
b.WriteString("Keep comments concise and natural — state the outcome, not the process.\n")
|
||||
b.WriteString("Good: \"Fixed the login redirect. PR: https://...\"\n")
|
||||
b.WriteString("Bad: \"1. Read the issue 2. Found the bug in auth.go 3. Created branch 4. ...\"\n")
|
||||
|
||||
@@ -89,34 +89,11 @@ func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// shutdownHandler triggers a graceful daemon shutdown by cancelling the
|
||||
// top-level context. Used by `multica daemon stop` so we don't depend on
|
||||
// OS-signal delivery, which is unreliable on Windows once the daemon is
|
||||
// spawned with DETACHED_PROCESS (no shared console with the stop caller).
|
||||
// The listener is bound to 127.0.0.1 only, so only local processes can hit
|
||||
// this endpoint.
|
||||
func (d *Daemon) shutdownHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "shutting down"})
|
||||
if d.cancelFunc != nil {
|
||||
// Cancel asynchronously so the response flushes first; otherwise
|
||||
// srv.Close() races with the writer.
|
||||
go d.cancelFunc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveHealth runs the health HTTP server on the given listener.
|
||||
// Blocks until ctx is cancelled.
|
||||
func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", d.healthHandler(startedAt))
|
||||
mux.HandleFunc("/shutdown", d.shutdownHandler())
|
||||
|
||||
mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -87,49 +86,6 @@ func TestHealthHandlerActiveTaskCountTracksCounter(t *testing.T) {
|
||||
assertActiveTaskCount(t, handler, 0)
|
||||
}
|
||||
|
||||
func TestShutdownHandlerPostCancelsDaemonContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
d := &Daemon{cancelFunc: cancel}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/shutdown", nil)
|
||||
d.shutdownHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("daemon context was not cancelled after POST /shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownHandlerRejectsNonPost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cancelled := false
|
||||
d := &Daemon{cancelFunc: func() { cancelled = true }}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/shutdown", nil)
|
||||
d.shutdownHandler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("expected 405, got %d", rec.Code)
|
||||
}
|
||||
// Give the handler's deferred cancel goroutine a moment to fire
|
||||
// in case a bug causes it to run anyway.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if cancelled {
|
||||
t.Fatal("GET request should not trigger cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func assertActiveTaskCount(t *testing.T, h http.HandlerFunc, want int64) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -3,8 +3,6 @@ package daemon
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
||||
)
|
||||
|
||||
// BuildPrompt constructs the task prompt for an agent CLI.
|
||||
@@ -27,9 +25,6 @@ func BuildPrompt(task Task) string {
|
||||
// buildCommentPrompt constructs a prompt for comment-triggered tasks.
|
||||
// The triggering comment content is embedded directly so the agent cannot
|
||||
// miss it, even when stale output files exist in a reused workdir.
|
||||
// The reply instructions (including the current TriggerCommentID as --parent)
|
||||
// are re-emitted on every turn so resumed sessions cannot carry forward a
|
||||
// previous turn's --parent UUID.
|
||||
func buildCommentPrompt(task Task) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
|
||||
@@ -38,8 +33,7 @@ func buildCommentPrompt(task Task) string {
|
||||
b.WriteString("[NEW COMMENT] A user just left a new comment that triggered this task. You MUST respond to THIS comment, not any previous ones:\n\n")
|
||||
fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent)
|
||||
}
|
||||
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n\n", task.IssueID)
|
||||
b.WriteString(execenv.BuildCommentReplyInstructions(task.IssueID, task.TriggerCommentID))
|
||||
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ type AgentData struct {
|
||||
CustomEnv map[string]string `json:"custom_env,omitempty"`
|
||||
CustomArgs []string `json:"custom_args,omitempty"`
|
||||
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
// SkillData represents a structured skill for task execution.
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
@@ -207,122 +206,6 @@ func TestCreateComment_WithParentID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateComment_AgentWithWrongParentRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Find the fixture agent + its runtime.
|
||||
var agentID, runtimeID string
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 AND name = $2`,
|
||||
testWorkspaceID, "Handler Test Agent",
|
||||
).Scan(&agentID, &runtimeID); err != nil {
|
||||
t.Fatalf("find test agent: %v", err)
|
||||
}
|
||||
|
||||
// Two issues: A hosts the comment-triggered task; B exists to prove the
|
||||
// guard is scoped to the task's own issue and does not block cross-issue
|
||||
// agent activity. (The CLI stamps X-Task-ID on every request, so an agent
|
||||
// legitimately commenting on another issue must still succeed.)
|
||||
createIssue := func(title string) string {
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{"title": title})
|
||||
testHandler.CreateIssue(w, r)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue(%s): %d: %s", title, w.Code, w.Body.String())
|
||||
}
|
||||
var issue IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&issue)
|
||||
return issue.ID
|
||||
}
|
||||
issueA := createIssue("agent parent guard test — issue A")
|
||||
issueB := createIssue("agent parent guard test — issue B")
|
||||
|
||||
var freshTaskID string
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, freshTaskID)
|
||||
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id IN ($1, $2)`, issueA, issueB)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id IN ($1, $2)`, issueA, issueB)
|
||||
})
|
||||
|
||||
postComment := func(t *testing.T, issueID string, body map[string]any, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
|
||||
r = withURLParam(r, "id", issueID)
|
||||
for k, v := range headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
testHandler.CreateComment(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
w := postComment(t, issueA, map[string]any{"content": "stale comment"}, nil)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create stale parent: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var staleParent CommentResponse
|
||||
json.NewDecoder(w.Body).Decode(&staleParent)
|
||||
|
||||
w = postComment(t, issueA, map[string]any{"content": "fresh comment"}, nil)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create fresh parent: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var freshParent CommentResponse
|
||||
json.NewDecoder(w.Body).Decode(&freshParent)
|
||||
|
||||
// Comment-triggered task bound to issueA.
|
||||
if err := testPool.QueryRow(ctx,
|
||||
`INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, trigger_comment_id)
|
||||
VALUES ($1, $2, $3, 'queued', 0, $4) RETURNING id`,
|
||||
agentID, runtimeID, issueA, freshParent.ID,
|
||||
).Scan(&freshTaskID); err != nil {
|
||||
t.Fatalf("insert fresh task: %v", err)
|
||||
}
|
||||
|
||||
agentHeaders := map[string]string{"X-Agent-ID": agentID, "X-Task-ID": freshTaskID}
|
||||
|
||||
// Same issue + wrong parent → 409.
|
||||
w = postComment(t, issueA,
|
||||
map[string]any{"content": "drifted reply", "parent_id": staleParent.ID},
|
||||
agentHeaders,
|
||||
)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409 when agent replies with wrong parent, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), freshParent.ID) {
|
||||
t.Fatalf("expected error body to reference the correct trigger comment id, got %s", w.Body.String())
|
||||
}
|
||||
|
||||
// Same issue + no parent → 409 (must reply to trigger).
|
||||
w = postComment(t, issueA,
|
||||
map[string]any{"content": "no parent"},
|
||||
agentHeaders,
|
||||
)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409 when agent replies with no parent, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Same issue + correct parent → 201.
|
||||
w = postComment(t, issueA,
|
||||
map[string]any{"content": "correct reply", "parent_id": freshParent.ID},
|
||||
agentHeaders,
|
||||
)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 when agent replies with matching parent, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Cross-issue: agent carries X-Task-ID (bound to issueA) but comments on
|
||||
// issueB. The guard must NOT fire — this is the cross-issue regression
|
||||
// covering the fix for gpt-boy's review.
|
||||
w = postComment(t, issueB,
|
||||
map[string]any{"content": "cross-issue note"},
|
||||
agentHeaders,
|
||||
)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("agent posting on a different issue should not be blocked by its current task's trigger, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentWithParentID_AppearsInTimeline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ type AgentResponse struct {
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
Model string `json:"model"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
Skills []SkillResponse `json:"skills"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
@@ -95,7 +94,6 @@ func agentToResponse(a db.Agent) AgentResponse {
|
||||
Visibility: a.Visibility,
|
||||
Status: a.Status,
|
||||
MaxConcurrentTasks: a.MaxConcurrentTasks,
|
||||
Model: a.Model.String,
|
||||
OwnerID: uuidToPtr(a.OwnerID),
|
||||
Skills: []SkillResponse{},
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
@@ -146,7 +144,6 @@ type TaskAgentData struct {
|
||||
CustomEnv map[string]string `json:"custom_env,omitempty"`
|
||||
CustomArgs []string `json:"custom_args,omitempty"`
|
||||
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
@@ -268,7 +265,6 @@ type CreateAgentRequest struct {
|
||||
McpConfig json.RawMessage `json:"mcp_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
func decodeJSONBodyWithRawFields(body io.Reader, dst any) (map[string]json.RawMessage, error) {
|
||||
@@ -366,7 +362,6 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
CustomEnv: ce,
|
||||
CustomArgs: ca,
|
||||
McpConfig: mc,
|
||||
Model: pgtype.Text{String: req.Model, Valid: req.Model != ""},
|
||||
})
|
||||
if err != nil {
|
||||
// Unique constraint on (workspace_id, name) — return a clear conflict error
|
||||
@@ -406,7 +401,6 @@ type UpdateAgentRequest struct {
|
||||
Visibility *string `json:"visibility"`
|
||||
Status *string `json:"status"`
|
||||
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
|
||||
Model *string `json:"model"`
|
||||
}
|
||||
|
||||
// canViewAgentEnv checks whether the requesting user is allowed to see the
|
||||
@@ -529,9 +523,6 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
if req.MaxConcurrentTasks != nil {
|
||||
params.MaxConcurrentTasks = pgtype.Int4{Int32: *req.MaxConcurrentTasks, Valid: true}
|
||||
}
|
||||
if req.Model != nil {
|
||||
params.Model = pgtype.Text{String: *req.Model, Valid: true}
|
||||
}
|
||||
|
||||
agent, err = h.Queries.UpdateAgent(r.Context(), params)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
@@ -18,24 +17,11 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/analytics"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// SignupError represents signup restriction errors
|
||||
type SignupError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e SignupError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"}
|
||||
var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"}
|
||||
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -90,109 +76,25 @@ func (h *Handler) issueJWT(user db.User) (string, error) {
|
||||
return token.SignedString(auth.JWTSecret())
|
||||
}
|
||||
|
||||
// findOrCreateUser returns the existing user for an email, or creates one if
|
||||
// none exists. isNew reports whether this call created the user — the signup
|
||||
// event fires on that edge, covering both the verification-code and Google
|
||||
// OAuth entry points.
|
||||
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (user db.User, isNew bool, err error) {
|
||||
user, err = h.Queries.GetUserByEmail(ctx, email)
|
||||
isNew = isNotFound(err)
|
||||
if err != nil && !isNew {
|
||||
return db.User{}, false, err
|
||||
}
|
||||
|
||||
if err := h.checkSignupAllowed(email, isNew); err != nil {
|
||||
return db.User{}, false, err
|
||||
}
|
||||
|
||||
if !isNew {
|
||||
return user, false, nil
|
||||
}
|
||||
|
||||
name := email
|
||||
if at := strings.Index(email, "@"); at > 0 {
|
||||
name = email[:at]
|
||||
}
|
||||
created, err := h.Queries.CreateUser(ctx, db.CreateUserParams{
|
||||
Name: name,
|
||||
Email: email,
|
||||
})
|
||||
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) {
|
||||
user, err := h.Queries.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return db.User{}, false, err
|
||||
}
|
||||
return created, true, nil
|
||||
}
|
||||
|
||||
// signupSourceFromRequest reads the attribution cookie the web frontend
|
||||
// sets on the first pageview (UTM + referrer bundle). The frontend writes
|
||||
// a JSON string URL-encoded into the cookie value — Go does not
|
||||
// auto-decode Cookie.Value, so we have to unescape here before the string
|
||||
// lands in PostHog. Missing cookie / decode failures collapse to the
|
||||
// empty string; that simply omits signup_source from the event rather
|
||||
// than sending percent-encoded garbage. Never fall back to r.Referer() —
|
||||
// the frontend has already sanitised attribution and a raw referer can
|
||||
// leak OAuth code/state from the callback URL.
|
||||
//
|
||||
// The cap is the server-side defence against a client that manages to set
|
||||
// an oversize cookie; it matches SIGNUP_SOURCE_MAX_LEN on the frontend.
|
||||
const signupSourceMaxLen = 512
|
||||
|
||||
func signupSourceFromRequest(r *http.Request) string {
|
||||
c, err := r.Cookie("multica_signup_source")
|
||||
if err != nil || c == nil {
|
||||
return ""
|
||||
}
|
||||
decoded, err := url.QueryUnescape(c.Value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(decoded) > signupSourceMaxLen {
|
||||
return ""
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func (h *Handler) checkSignupAllowed(email string, isNewUser bool) error {
|
||||
if !isNewUser {
|
||||
return nil // existing users always allowed to log in
|
||||
}
|
||||
|
||||
email = strings.ToLower(email)
|
||||
domain := ""
|
||||
if at := strings.Index(email, "@"); at > 0 {
|
||||
domain = email[at+1:]
|
||||
}
|
||||
|
||||
// 1. explicit email whitelist always wins
|
||||
if len(h.cfg.AllowedEmails) > 0 && contains(h.cfg.AllowedEmails, email) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. domain whitelist always wins
|
||||
if len(h.cfg.AllowedEmailDomains) > 0 && contains(h.cfg.AllowedEmailDomains, domain) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. general signup flag
|
||||
if !h.cfg.AllowSignup {
|
||||
return ErrSignupProhibited
|
||||
}
|
||||
|
||||
// 4. if allowlists are set but didn't match, block
|
||||
if len(h.cfg.AllowedEmailDomains) > 0 || len(h.cfg.AllowedEmails) > 0 {
|
||||
return ErrSignupProhibited
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(slice []string, s string) bool {
|
||||
for _, item := range slice {
|
||||
if strings.EqualFold(item, s) {
|
||||
return true
|
||||
if !isNotFound(err) {
|
||||
return db.User{}, err
|
||||
}
|
||||
name := email
|
||||
if at := strings.Index(email, "@"); at > 0 {
|
||||
name = email[:at]
|
||||
}
|
||||
user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{
|
||||
Name: name,
|
||||
Email: email,
|
||||
})
|
||||
if err != nil {
|
||||
return db.User{}, err
|
||||
}
|
||||
}
|
||||
return false
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -208,40 +110,6 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check signup restrictions before sending magic link
|
||||
_, err := h.Queries.GetUserByEmail(r.Context(), email)
|
||||
if err != nil {
|
||||
if !isNotFound(err) {
|
||||
// Real database/query error → return 500
|
||||
writeError(w, http.StatusInternalServerError, "failed to lookup user")
|
||||
return
|
||||
}
|
||||
// User does not exist → treat as new user
|
||||
isNewUser := true
|
||||
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
|
||||
var signupErr SignupError
|
||||
if errors.As(err, &signupErr) {
|
||||
writeError(w, http.StatusForbidden, signupErr.Error())
|
||||
} else {
|
||||
writeError(w, http.StatusForbidden, "user registration is disabled")
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// User already exists → always allowed to login
|
||||
isNewUser := false
|
||||
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
|
||||
// This should rarely happen, but handle it anyway
|
||||
var signupErr SignupError
|
||||
if errors.As(err, &signupErr) {
|
||||
writeError(w, http.StatusForbidden, signupErr.Error())
|
||||
} else {
|
||||
writeError(w, http.StatusForbidden, "user registration is disabled")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit: max 1 code per 60 seconds per email
|
||||
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
|
||||
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
|
||||
@@ -310,19 +178,11 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
user, isNew, err := h.findOrCreateUser(r.Context(), email)
|
||||
user, err := h.findOrCreateUser(r.Context(), email)
|
||||
if err != nil {
|
||||
var signupErr SignupError
|
||||
if errors.As(err, &signupErr) {
|
||||
writeError(w, http.StatusForbidden, signupErr.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
if isNew {
|
||||
h.Analytics.Capture(analytics.Signup(uuidToString(user.ID), user.Email, signupSourceFromRequest(r)))
|
||||
}
|
||||
|
||||
tokenString, err := h.issueJWT(user)
|
||||
if err != nil {
|
||||
@@ -474,21 +334,11 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(gUser.Email))
|
||||
|
||||
user, isNew, err := h.findOrCreateUser(r.Context(), email)
|
||||
user, err := h.findOrCreateUser(r.Context(), email)
|
||||
if err != nil {
|
||||
var signupErr SignupError
|
||||
if errors.As(err, &signupErr) {
|
||||
writeError(w, http.StatusForbidden, signupErr.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
if isNew {
|
||||
evt := analytics.Signup(uuidToString(user.ID), user.Email, signupSourceFromRequest(r))
|
||||
evt.Properties["auth_method"] = "google"
|
||||
h.Analytics.Capture(evt)
|
||||
}
|
||||
|
||||
// Update name and avatar from Google profile if the user was just created
|
||||
// (default name is email prefix) or has no avatar yet.
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
func newTestHandler(cfg Config) *Handler {
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignupGating(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
email string
|
||||
isNew bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"allow_signup_true_new", Config{AllowSignup: true}, "a@x.com", true, false},
|
||||
{"allow_signup_false_new", Config{AllowSignup: false}, "a@x.com", true, true},
|
||||
{"allow_signup_false_existing", Config{AllowSignup: false}, "a@x.com", false, false},
|
||||
{"domain_allowlist_match", Config{AllowSignup: false, AllowedEmailDomains: []string{"company.com"}}, "user@company.com", true, false},
|
||||
{"domain_allowlist_mismatch", Config{AllowSignup: false, AllowedEmailDomains: []string{"company.com"}}, "user@other.com", true, true},
|
||||
{"email_allowlist_match", Config{AllowSignup: false, AllowedEmails: []string{"boss@x.com"}}, "boss@x.com", true, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := newTestHandler(tt.cfg)
|
||||
err := h.checkSignupAllowed(tt.email, tt.isNew)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("got err=%v wantErr=%v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockDB struct {
|
||||
db.DBTX
|
||||
getUserErr error
|
||||
}
|
||||
|
||||
func (m *mockDB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
||||
return &mockRow{err: m.getUserErr}
|
||||
}
|
||||
|
||||
func (m *mockDB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
|
||||
return pgconn.NewCommandTag("INSERT 1"), nil
|
||||
}
|
||||
|
||||
type mockRow struct {
|
||||
pgx.Row
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockRow) Scan(dest ...interface{}) error {
|
||||
return m.err
|
||||
}
|
||||
|
||||
func TestFindOrCreateUserGating(t *testing.T) {
|
||||
t.Run("new_user_blocked", func(t *testing.T) {
|
||||
cfg := Config{AllowSignup: false}
|
||||
h := newTestHandler(cfg)
|
||||
h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows})
|
||||
|
||||
_, isNew, err := h.findOrCreateUser(context.Background(), "new@blocked.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for new user when signup disabled")
|
||||
}
|
||||
if isNew {
|
||||
t.Fatal("isNew should be false when signup is blocked")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "registration is disabled") {
|
||||
t.Fatalf("expected registration disabled error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing_user_allowed", func(t *testing.T) {
|
||||
cfg := Config{AllowSignup: false}
|
||||
h := newTestHandler(cfg)
|
||||
// mockDB returns nil error for Scan, simulating user found
|
||||
h.Queries = db.New(&mockDB{getUserErr: nil})
|
||||
|
||||
_, isNew, err := h.findOrCreateUser(context.Background(), "existing@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for existing user, got %v", err)
|
||||
}
|
||||
if isNew {
|
||||
t.Fatal("existing user should not be flagged as new")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitelisted_user_allowed", func(t *testing.T) {
|
||||
cfg := Config{AllowSignup: false, AllowedEmails: []string{"whitelisted@test.com"}}
|
||||
h := newTestHandler(cfg)
|
||||
h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows})
|
||||
|
||||
// This will pass checkSignupAllowed and move to CreateUser.
|
||||
// Our mockDB Exec returns success, but Queries.CreateUser might expect QueryRow for RETURNING id.
|
||||
// Let's see if it works.
|
||||
_, _, err := h.findOrCreateUser(context.Background(), "whitelisted@test.com")
|
||||
if err != nil && strings.Contains(err.Error(), "registration is disabled") {
|
||||
t.Fatalf("expected whitelisted user to pass signup check, but got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -216,29 +216,6 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
// Determine author identity: agent (via X-Agent-ID header) or member.
|
||||
authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
|
||||
|
||||
// Defense against resumed-session drift: when an agent posts from inside a
|
||||
// comment-triggered task AND the comment is being posted on that same
|
||||
// issue, the parent_id must exactly match the task's trigger comment.
|
||||
// Resumed Claude sessions otherwise carry forward a previous turn's
|
||||
// --parent UUID and silently misplace the reply.
|
||||
//
|
||||
// The task.IssueID scope is important: the CLI stamps X-Task-ID on every
|
||||
// request, so an agent legitimately commenting on a different issue must
|
||||
// not be blocked by its current task's trigger. Assignment-triggered
|
||||
// tasks (no TriggerCommentID) are also unaffected.
|
||||
if authorType == "agent" {
|
||||
if taskIDHeader := r.Header.Get("X-Task-ID"); taskIDHeader != "" {
|
||||
task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskIDHeader))
|
||||
if err == nil && task.TriggerCommentID.Valid && uuidToString(task.IssueID) == uuidToString(issue.ID) {
|
||||
if uuidToString(parentID) != uuidToString(task.TriggerCommentID) {
|
||||
writeError(w, http.StatusConflict,
|
||||
"parent_id must equal this task's trigger comment id ("+uuidToString(task.TriggerCommentID)+")")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand bare issue identifiers (e.g. MUL-117) into mention links.
|
||||
req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, issue.WorkspaceID, req.Content)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user