Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
305e54e6a0 fix(views): persist unsent comment/reply drafts across unmount
Comment and reply inputs kept draft text in a component-local editor
ref, so the text vanished when the component unmounted — switching
issues, collapsing a comment card, or toggling reply on another
comment. Persist drafts in a workspace-scoped Zustand store keyed by
issue (main comment) or issue+comment (reply), seed the editor from
the store on mount, and clear the entry on successful submit.
2026-04-20 00:40:34 +08:00
150 changed files with 708 additions and 10012 deletions

View File

@@ -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=

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"; \

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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);

View File

@@ -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.

View 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",
]);
});
});

View File

@@ -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();
}

View File

@@ -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/);
});
});

View File

@@ -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}`);
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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&apos;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>
);
}

View File

@@ -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 />,
},
]}
/>
),

View File

@@ -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

View File

@@ -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,
});
}
});
});

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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",

View File

@@ -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 RuntimeMoonshot 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",

View File

@@ -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",

View File

@@ -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.

View File

@@ -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 "";
}

View File

@@ -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");
}

View File

@@ -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 });
},

View 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}`;
}

View File

@@ -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,

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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 });
});
}, []);

View File

@@ -1,4 +1,3 @@
export * from "./queries";
export * from "./mutations";
export * from "./hooks";
export * from "./models";

View File

@@ -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,
});
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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>

View File

@@ -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";
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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>
)}

View File

@@ -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>
)}

View File

@@ -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>
);

View File

@@ -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;
},
};
},
});
}

View File

@@ -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!),
);
}

View File

@@ -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>

View File

@@ -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)}

View 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

View File

@@ -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 */}

View File

@@ -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)}

View 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
View File

@@ -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: {}

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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())
}
})
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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() {

View File

@@ -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
}

View File

@@ -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() {}

View File

@@ -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)
}
}
}

View File

@@ -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:])
}

View File

@@ -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)))
}

View File

@@ -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.

View File

@@ -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")
}
}
}

View File

@@ -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)
}

View File

@@ -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")
}
})
}

View File

@@ -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() {}

View File

@@ -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)
}

View File

@@ -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"`

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -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},

View File

@@ -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,
)
}

View File

@@ -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)
}
}
}

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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.

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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)
}
})
}

View File

@@ -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