From 29122cc18bac54ed8e5a12a846359ee90b4c4a31 Mon Sep 17 00:00:00 2001 From: Muhammadrizo Date: Mon, 27 Apr 2026 08:41:03 +0500 Subject: [PATCH 01/38] feat(sidebar): add dot to show the user about new invintation (#1711) --- packages/views/layout/app-sidebar.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 751b95e593..748edcaa6c 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -447,7 +447,12 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } - + + + {myInvitations.length > 0 && ( + + )} + {workspace?.name ?? "Multica"} From 1f770813dd4587ab45489cdddd7fb73b042e76e8 Mon Sep 17 00:00:00 2001 From: supercon99 Date: Sun, 26 Apr 2026 23:16:15 -0500 Subject: [PATCH 02/38] fix(selfhost): pass ALLOW_SIGNUP / ALLOWED_EMAILS / ALLOWED_EMAIL_DOMAINS to backend (#1726) docker-compose.selfhost.yml documents these as load-bearing in .env.example but the backend service never received them, so allowlist / signup-gating configs were silently ignored on self-hosted deployments. Wires the three vars through with defaults matching .env.example. --- docker-compose.selfhost.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index bdbd01afac..be381ee42c 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -56,6 +56,9 @@ services: COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} APP_ENV: ${APP_ENV:-production} MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000} + ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true} + ALLOWED_EMAILS: ${ALLOWED_EMAILS:-} + ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-} restart: unless-stopped frontend: From 8b340fcf212bb9d083c20660d54acad441c75e1c Mon Sep 17 00:00:00 2001 From: Truffle Date: Sun, 26 Apr 2026 21:16:56 -0700 Subject: [PATCH 03/38] fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts (#1718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts The npm-generated `opencode.cmd` shim forwards argv via Windows batch `%*`, which silently truncates positional arguments at the first newline. The daemon spawns OpenCode with a multi-line prompt (system prompt + user message), so on Windows the agent only ever sees the first line and responds generically as if it never received the user's message (reported in #1717 with native-binary repro confirming the same prompt arrives intact when cmd.exe is skipped). When `runtime.GOOS == "windows"` and `exec.LookPath` returns a `.cmd` shim, walk to the native binary that npm bundles next to the shim: \opencode.cmd \node_modules\opencode-ai\node_modules\opencode-windows-x64\bin\opencode.exe If the native binary is missing (unusual install layout), keep the original shim path so PATH lookup still wins. The resolver is a pure function with an injectable `statFn`, so layout assertions are testable on Linux: - shim resolves to the bundled native binary - missing native returns "" (caller keeps original path) - non-cmd paths (Linux/Mac binary, opencode.exe direct, empty) skip resolution - uppercase `.CMD` is accepted (PATHEXT entries can be either case) Closes the user-facing failure mode without restructuring exec resolution across the rest of the agent backends — the other shim-aware fixes can follow the same shape if/when they land in similar repros. * fix(agent/opencode): cover x64-baseline and arm64 npm package variants `npm install -g opencode-ai` ships three Windows platform packages (opencode-windows-x64, opencode-windows-x64-baseline for older CPUs without AVX2, opencode-windows-arm64 for Surface / Copilot+ PC) and installs whichever matches the host. The previous resolver only knew about opencode-windows-x64, so baseline-x64 and arm64 hosts would fall back to the .cmd shim and hit the multi-line prompt truncation again. Iterate the three package candidates in GOARCH-preferred order. ARM64 hosts try arm64 first; everything else tries x64, then baseline, then arm64 as a last resort. Cost is one extra statFn call per miss when the GOARCH-preferred package isn't installed. Surfaced by review on #1718. * test(agent): add Windows counterpart to writeTestExecutable writeTestExecutable in exec_fixture_unix_test.go is referenced by claude_test.go / codex_test.go / kimi_test.go, but the //go:build unix constraint meant `go test ./pkg/agent` failed to build on Windows. ETXTBSY is a Linux/Unix fork-exec race; Windows doesn't have that pathology, so a plain os.WriteFile is sufficient. Lifted from #1719 (Codex) with attribution. Surfaced by review on #1718. --- server/pkg/agent/exec_fixture_windows_test.go | 24 ++++ server/pkg/agent/opencode.go | 70 ++++++++- server/pkg/agent/opencode_test.go | 133 ++++++++++++++++++ 3 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 server/pkg/agent/exec_fixture_windows_test.go diff --git a/server/pkg/agent/exec_fixture_windows_test.go b/server/pkg/agent/exec_fixture_windows_test.go new file mode 100644 index 0000000000..37e3d6d2a5 --- /dev/null +++ b/server/pkg/agent/exec_fixture_windows_test.go @@ -0,0 +1,24 @@ +//go:build windows + +package agent + +import ( + "os" + "testing" +) + +// writeTestExecutable is the Windows counterpart to the //go:build unix +// implementation in exec_fixture_unix_test.go. ETXTBSY is a Linux/Unix +// fork-exec race; Windows doesn't have that pathology, so a plain +// os.WriteFile is sufficient. +// +// The helper is referenced by claude_test.go / codex_test.go / +// kimi_test.go, so the absence of a Windows impl made +// `go test ./pkg/agent` fail to build on Windows. Lifted from #1719 +// (Codex) with attribution. +func writeTestExecutable(tb testing.TB, path string, content []byte) { + tb.Helper() + if err := os.WriteFile(path, content, 0o755); err != nil { + tb.Fatalf("write test executable %s: %v", path, err) + } +} diff --git a/server/pkg/agent/opencode.go b/server/pkg/agent/opencode.go index e8887d6f57..e6b27502b4 100644 --- a/server/pkg/agent/opencode.go +++ b/server/pkg/agent/opencode.go @@ -6,7 +6,10 @@ import ( "encoding/json" "fmt" "io" + "os" "os/exec" + "path/filepath" + "runtime" "strings" "time" ) @@ -28,9 +31,17 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO if execPath == "" { execPath = "opencode" } - if _, err := exec.LookPath(execPath); err != nil { + resolved, err := exec.LookPath(execPath) + if err != nil { return nil, fmt.Errorf("opencode executable not found at %q: %w", execPath, err) } + if runtime.GOOS == "windows" { + if native := resolveOpenCodeNativeFromShim(resolved, os.Stat); native != "" { + b.cfg.Logger.Info("opencode resolved to native binary to avoid .cmd shim argv truncation", "shim", resolved, "native", native) + resolved = native + } + } + execPath = resolved timeout := opts.Timeout if timeout == 0 { @@ -275,6 +286,59 @@ func (b *opencodeBackend) handleErrorEvent(event opencodeEvent, ch chan<- Messag *finalError = errMsg } +// resolveOpenCodeNativeFromShim returns the path to the native OpenCode +// executable bundled inside the npm package, given the path to the npm +// `opencode.cmd` shim that PATH lookup found on Windows. Returns "" if shim +// doesn't end in `.cmd` or no candidate npm platform package has a bundled +// native binary present. +// +// Windows batch argument forwarding via `%*` does not preserve newlines, so +// multi-line positional argv is truncated at the first newline before the +// shim hands off to the JS entrypoint. Daemon prompts can include literal +// newlines (system prompt + user message), which makes the agent see only +// the first line. Native binary spawn skips the cmd.exe layer entirely. +// +// Layout when installed via `npm install -g opencode-ai`: +// +// \opencode.cmd (shim) +// \node_modules\opencode-ai\node_modules\opencode-windows-{x64,x64-baseline,arm64}\bin\opencode.exe (native) +// +// `opencode-windows-x64-baseline` ships for older CPUs without AVX2; +// `opencode-windows-arm64` ships for Surface / Copilot+ PC hosts. +// Candidates are tried in GOARCH-preferred order so the most likely match +// for the current host comes first. +// +// statFn is injected so this is testable on non-Windows hosts. +func resolveOpenCodeNativeFromShim(shimPath string, statFn func(string) (os.FileInfo, error)) string { + if !strings.EqualFold(filepath.Ext(shimPath), ".cmd") { + return "" + } + prefix := filepath.Dir(shimPath) + for _, pkg := range opencodeWindowsPackageCandidates(runtime.GOARCH) { + candidate := filepath.Join(prefix, "node_modules", "opencode-ai", "node_modules", pkg, "bin", "opencode.exe") + if _, err := statFn(candidate); err == nil { + return candidate + } + } + return "" +} + +// opencodeWindowsPackageCandidates returns the npm platform package names +// that may host the bundled `opencode.exe` on Windows, ordered so the most +// likely match for the given GOARCH comes first. ARM64 hosts try the arm64 +// build first; everything else tries x64, then the baseline x64 build for +// older CPUs without AVX2, then arm64 as a final fallback. Cost is one +// extra statFn call per miss when the GOARCH-preferred package isn't +// installed. +func opencodeWindowsPackageCandidates(goarch string) []string { + switch goarch { + case "arm64": + return []string{"opencode-windows-arm64", "opencode-windows-x64", "opencode-windows-x64-baseline"} + default: + return []string{"opencode-windows-x64", "opencode-windows-x64-baseline", "opencode-windows-arm64"} + } +} + // extractToolOutput converts the tool state output (which may be a string or // structured object) into a string. func extractToolOutput(output any) string { @@ -328,8 +392,8 @@ type opencodeEventPart struct { // opencodeTokens represents token usage in a step_finish event. type opencodeTokens struct { - Input int64 `json:"input"` - Output int64 `json:"output"` + Input int64 `json:"input"` + Output int64 `json:"output"` Cache *opencodeCacheTokens `json:"cache,omitempty"` } diff --git a/server/pkg/agent/opencode_test.go b/server/pkg/agent/opencode_test.go index 73c6d491cf..8ecb101066 100644 --- a/server/pkg/agent/opencode_test.go +++ b/server/pkg/agent/opencode_test.go @@ -2,8 +2,11 @@ package agent import ( "encoding/json" + "errors" "fmt" "log/slog" + "os" + "path/filepath" "strings" "testing" ) @@ -709,3 +712,133 @@ func TestOpencodeProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) { close(ch) } + +// ── Windows native-binary resolution tests ── + +// fakeStat returns a statFn that reports any path in `present` as existing +// and every other path as not-found. The returned os.FileInfo is a stub +// because resolveOpenCodeNativeFromShim only inspects the error. +func fakeStat(present ...string) func(string) (os.FileInfo, error) { + set := make(map[string]struct{}, len(present)) + for _, p := range present { + set[p] = struct{}{} + } + return func(path string) (os.FileInfo, error) { + if _, ok := set[path]; ok { + return nil, nil + } + return nil, errors.New("not found") + } +} + +func TestResolveOpenCodeNativeFromShimResolvesNpmShim(t *testing.T) { + t.Parallel() + + // Reporter's exact layout from multica#1717. + shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd") + native := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin", "opencode.exe") + + got := resolveOpenCodeNativeFromShim(shim, fakeStat(native)) + if got != native { + t.Errorf("got %q, want %q", got, native) + } +} + +func TestResolveOpenCodeNativeFromShimReturnsEmptyWhenNativeMissing(t *testing.T) { + t.Parallel() + + // Shim ends in .cmd but the bundled native binary isn't present (e.g. + // platform package didn't install or layout changed). Caller must keep + // the original shim path so PATH lookup still wins. + shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd") + + got := resolveOpenCodeNativeFromShim(shim, fakeStat()) + if got != "" { + t.Errorf("got %q, want empty (missing native binary)", got) + } +} + +func TestResolveOpenCodeNativeFromShimSkipsNonCmdPath(t *testing.T) { + t.Parallel() + + // On macOS/Linux the path returned by exec.LookPath is the native + // binary itself, with no .cmd extension. Helper should signal "no + // rewrite needed" by returning empty. + cases := []string{ + "/usr/local/bin/opencode", + "C:\\nvm4w\\nodejs\\opencode.exe", + "", + } + for _, p := range cases { + if got := resolveOpenCodeNativeFromShim(p, fakeStat("anything")); got != "" { + t.Errorf("path %q: got %q, want empty", p, got) + } + } +} + +func TestResolveOpenCodeNativeFromShimAcceptsUppercaseExtension(t *testing.T) { + t.Parallel() + + // Windows is case-insensitive on filesystem extensions. PATHEXT tokens + // are commonly uppercase, and exec.LookPath can return either case. + shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.CMD") + native := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin", "opencode.exe") + + got := resolveOpenCodeNativeFromShim(shim, fakeStat(native)) + if got != native { + t.Errorf("got %q, want %q", got, native) + } +} + +func TestResolveOpenCodeNativeFromShimFallsBackToBaseline(t *testing.T) { + t.Parallel() + + // Older CPUs without AVX2 get `opencode-windows-x64-baseline` instead of + // the default x64 build. Resolver should fall through and find it when + // the primary x64 package isn't installed. + shim := filepath.Join("C:\\nvm4w", "nodejs", "opencode.cmd") + baseline := filepath.Join("C:\\nvm4w", "nodejs", "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe") + + got := resolveOpenCodeNativeFromShim(shim, fakeStat(baseline)) + if got != baseline { + t.Errorf("got %q, want %q", got, baseline) + } +} + +func TestOpencodeWindowsPackageCandidatesArm64(t *testing.T) { + t.Parallel() + + // ARM64 hosts (Surface, Copilot+ PC) should try arm64 first so the + // resolver doesn't accidentally pick up a leftover x64 install when + // the matching arm64 package is present. + got := opencodeWindowsPackageCandidates("arm64") + want := []string{"opencode-windows-arm64", "opencode-windows-x64", "opencode-windows-x64-baseline"} + if !equalStringSlice(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestOpencodeWindowsPackageCandidatesAmd64(t *testing.T) { + t.Parallel() + + // amd64 (and any non-arm64) hosts try x64 → baseline → arm64. The arm64 + // fallback at the end covers the unusual case where only the arm64 + // package is installed; resolution still succeeds. + got := opencodeWindowsPackageCandidates("amd64") + want := []string{"opencode-windows-x64", "opencode-windows-x64-baseline", "opencode-windows-arm64"} + if !equalStringSlice(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} From 7f6776b12f6850ff11893548adcbcd003dc5f6f1 Mon Sep 17 00:00:00 2001 From: devv-eve Date: Mon, 27 Apr 2026 13:01:53 +0800 Subject: [PATCH 04/38] fix: harden Windows CLI architecture detection * fix: harden windows cli architecture detection * fix: avoid duplicate windows architecture signals --------- Co-authored-by: Eve --- scripts/install.ps1 | 102 +++++++++++++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 29 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 02c1560f9e..5233b9166c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -88,6 +88,78 @@ function Pull-OfficialSelfHostImages { exit 1 } +function Convert-ToCliArch { + param([object]$Value) + + if ($null -eq $Value) { + return $null + } + + $normalized = "$Value".Trim().ToUpperInvariant() + switch ($normalized) { + "9" { return "amd64" } + "AMD64" { return "amd64" } + "X64" { return "amd64" } + "X86_64" { return "amd64" } + "12" { return "arm64" } + "ARM64" { return "arm64" } + "AARCH64" { return "arm64" } + default { return $null } + } +} + +function Get-WindowsCliArch { + $signals = @() + $nativeArchSignalFound = $false + + # Prefer the native processor architecture over the current PowerShell + # process architecture. This keeps Windows on ARM from being misdetected + # when PowerShell is running through x64/x86 emulation. + try { + if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { + $processorArch = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop | + Select-Object -First 1 -ExpandProperty Architecture + $signals += [pscustomobject]@{ Source = "Win32_Processor.Architecture"; Value = $processorArch } + $nativeArchSignalFound = $true + } + } catch {} + + try { + if (-not $nativeArchSignalFound -and (Get-Command Get-WmiObject -ErrorAction SilentlyContinue)) { + $processorArch = Get-WmiObject -Class Win32_Processor -ErrorAction Stop | + Select-Object -First 1 -ExpandProperty Architecture + $signals += [pscustomobject]@{ Source = "Win32_Processor.Architecture"; Value = $processorArch } + $nativeArchSignalFound = $true + } + } catch {} + + try { + $signals += [pscustomobject]@{ + Source = "RuntimeInformation.OSArchitecture" + Value = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + } + } catch {} + + $signals += [pscustomobject]@{ Source = "PROCESSOR_ARCHITEW6432"; Value = $env:PROCESSOR_ARCHITEW6432 } + $signals += [pscustomobject]@{ Source = "PROCESSOR_ARCHITECTURE"; Value = $env:PROCESSOR_ARCHITECTURE } + + foreach ($signal in $signals) { + $arch = Convert-ToCliArch $signal.Value + if ($arch) { + return $arch + } + } + + $details = ($signals | + Where-Object { $null -ne $_.Value -and "$($_.Value)".Trim() -ne "" } | + ForEach-Object { "$($_.Source)=$($_.Value)" }) -join ", " + if (-not $details) { + $details = "no architecture signals available" + } + + Write-Fail "Unsupported Windows architecture ($details). Only x64 and ARM64 are supported." +} + # --------------------------------------------------------------------------- # CLI Installation # --------------------------------------------------------------------------- @@ -98,35 +170,7 @@ function Install-CliBinary { Write-Fail "Multica requires a 64-bit Windows installation." } - # Distinguish amd64 vs arm64 — Is64BitOperatingSystem is true for both. - # Use multiple detection methods for robustness - $osArch = $null - - # Method 1: RuntimeInformation (primary) - try { - $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture - } catch {} - - # Method 2: PROCESSOR_ARCHITECTURE environment variable - if (-not $osArch) { - $envArch = $env:PROCESSOR_ARCHITECTURE - if ($envArch -eq "AMD64") { $osArch = 'X64' } - elseif ($envArch -eq "ARM64") { $osArch = 'Arm64' } - } - - # Method 3: PROCESSOR_ARCHITEW6432 (for 32-bit PowerShell on 64-bit Windows) - if (-not $osArch) { - $envArch = $env:PROCESSOR_ARCHITEW6432 - if ($envArch -eq "AMD64") { $osArch = 'X64' } - elseif ($envArch -eq "ARM64") { $osArch = 'Arm64' } - } - - # Determine architecture - switch ($osArch) { - 'X64' { $arch = "amd64" } - 'Arm64' { $arch = "arm64" } - default { Write-Fail "Unsupported Windows architecture: $osArch (only X64 and Arm64 are supported)." } - } + $arch = Get-WindowsCliArch $latest = Get-LatestVersion if (-not $latest) { From ba2f19d6312145e884019d3ebe139dd52500bfd6 Mon Sep 17 00:00:00 2001 From: devv-eve Date: Mon, 27 Apr 2026 13:34:24 +0800 Subject: [PATCH 05/38] fix: refresh agent status from active tasks (#1733) Co-authored-by: Eve --- server/cmd/server/runtime_sweeper.go | 15 +------ server/cmd/server/runtime_sweeper_test.go | 43 ++++++++++++++++++++ server/internal/service/task.go | 32 +++++++-------- server/pkg/db/generated/agent.sql.go | 48 +++++++++++++++++++++-- server/pkg/db/queries/agent.sql | 10 +++++ 5 files changed, 115 insertions(+), 33 deletions(-) diff --git a/server/cmd/server/runtime_sweeper.go b/server/cmd/server/runtime_sweeper.go index 05ec0f8185..cd38494cae 100644 --- a/server/cmd/server/runtime_sweeper.go +++ b/server/cmd/server/runtime_sweeper.go @@ -198,21 +198,10 @@ func broadcastFailedTasks(ctx context.Context, queries *db.Queries, taskSvc *ser } } -// reconcileAgentStatus checks running task count and updates agent status. +// reconcileAgentStatus refreshes agent status from the current active task set. // Used only by the test-fallback path of broadcastFailedTasks above. func reconcileAgentStatus(ctx context.Context, queries *db.Queries, bus *events.Bus, agentID pgtype.UUID) { - running, err := queries.CountRunningTasks(ctx, agentID) - if err != nil { - return - } - newStatus := "idle" - if running > 0 { - newStatus = "working" - } - agent, err := queries.UpdateAgentStatus(ctx, db.UpdateAgentStatusParams{ - ID: agentID, - Status: newStatus, - }) + agent, err := queries.RefreshAgentStatusFromTasks(ctx, agentID) if err != nil { return } diff --git a/server/cmd/server/runtime_sweeper_test.go b/server/cmd/server/runtime_sweeper_test.go index f8f4c7c8ef..6d7cd93079 100644 --- a/server/cmd/server/runtime_sweeper_test.go +++ b/server/cmd/server/runtime_sweeper_test.go @@ -78,6 +78,49 @@ func cleanupSweeperFixture(t *testing.T, issueID, agentID string) { testPool.Exec(ctx, `UPDATE agent SET status = 'idle' WHERE id = $1`, agentID) } +func TestRefreshAgentStatusFromTasks(t *testing.T) { + if testPool == nil { + t.Skip("no database connection") + } + + ctx := context.Background() + issueID, agentID, taskID := setupSweeperTestFixture(t, "dispatched") + t.Cleanup(func() { cleanupSweeperFixture(t, issueID, agentID) }) + + queries := db.New(testPool) + + if _, err := testPool.Exec(ctx, `UPDATE agent SET status = 'idle' WHERE id = $1`, agentID); err != nil { + t.Fatalf("failed to seed idle agent status: %v", err) + } + + agent, err := queries.RefreshAgentStatusFromTasks(ctx, parseUUID(agentID)) + if err != nil { + t.Fatalf("RefreshAgentStatusFromTasks with dispatched task failed: %v", err) + } + if agent.Status != "working" { + t.Fatalf("expected dispatched task to refresh agent status to working, got %q", agent.Status) + } + + if _, err := testPool.Exec(ctx, ` + UPDATE agent_task_queue + SET status = 'cancelled', completed_at = now() + WHERE id = $1 + `, taskID); err != nil { + t.Fatalf("failed to cancel seeded task: %v", err) + } + if _, err := testPool.Exec(ctx, `UPDATE agent SET status = 'working' WHERE id = $1`, agentID); err != nil { + t.Fatalf("failed to reseed working agent status: %v", err) + } + + agent, err = queries.RefreshAgentStatusFromTasks(ctx, parseUUID(agentID)) + if err != nil { + t.Fatalf("RefreshAgentStatusFromTasks with no active tasks failed: %v", err) + } + if agent.Status != "idle" { + t.Fatalf("expected cancelled-only task set to refresh agent status to idle, got %q", agent.Status) + } +} + // TestSweepStaleTasksBroadcastsWithWorkspaceID verifies that when the task sweeper // fails a stale running task, the task:failed event is broadcast with the correct // WorkspaceID so it reaches frontend WebSocket clients (events without WorkspaceID diff --git a/server/internal/service/task.go b/server/internal/service/task.go index fe885ff26b..e956c48ff5 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -192,7 +192,7 @@ func (s *TaskService) CancelTask(ctx context.Context, taskID pgtype.UUID) (*db.A func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.AgentTaskQueue, error) { start := time.Now() var ( - outcome = "unknown" + outcome = "unknown" getAgentMs, countRunningMs, claimAgentMs, updateStatusMs, dispatchMs int64 ) defer func() { @@ -235,10 +235,10 @@ func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.A slog.Info("task claimed", "task_id", util.UUIDToString(task.ID), "agent_id", util.UUIDToString(agentID)) - // Update agent status to working. Hits the DB and may publish events, - // so it must be inside the timed window. + // Refresh agent status from active tasks. This avoids a stale unconditional + // working write racing after a just-cancelled claim. t0 = time.Now() - s.updateAgentStatus(ctx, agentID, "working") + s.ReconcileAgentStatus(ctx, agentID) updateStatusMs = time.Since(t0).Milliseconds() // Broadcast task:dispatch. ResolveTaskWorkspaceID inside this path can @@ -257,10 +257,10 @@ func (s *TaskService) ClaimTask(ctx context.Context, agentID pgtype.UUID) (*db.A func (s *TaskService) ClaimTaskForRuntime(ctx context.Context, runtimeID pgtype.UUID) (*db.AgentTaskQueue, error) { start := time.Now() var ( - outcome = "no_task" - listMs, loopMs int64 - listCount, tried int - claimedFlag bool + outcome = "no_task" + listMs, loopMs int64 + listCount, tried int + claimedFlag bool ) defer func() { totalMs := time.Since(start).Milliseconds() @@ -806,18 +806,14 @@ func (s *TaskService) ReportProgress(ctx context.Context, taskID string, workspa }) } -// ReconcileAgentStatus checks running task count and sets agent status accordingly. +// ReconcileAgentStatus refreshes agent status from the current active task set. func (s *TaskService) ReconcileAgentStatus(ctx context.Context, agentID pgtype.UUID) { - running, err := s.Queries.CountRunningTasks(ctx, agentID) + agent, err := s.Queries.RefreshAgentStatusFromTasks(ctx, agentID) if err != nil { return } - newStatus := "idle" - if running > 0 { - newStatus = "working" - } - slog.Debug("agent status reconciled", "agent_id", util.UUIDToString(agentID), "status", newStatus, "running_tasks", running) - s.updateAgentStatus(ctx, agentID, newStatus) + slog.Debug("agent status reconciled", "agent_id", util.UUIDToString(agentID), "status", agent.Status) + s.publishAgentStatus(agent) } func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID, status string) { @@ -828,6 +824,10 @@ func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID if err != nil { return } + s.publishAgentStatus(agent) +} + +func (s *TaskService) publishAgentStatus(agent db.Agent) { s.Bus.Publish(events.Event{ Type: protocol.EventAgentStatus, WorkspaceID: util.UUIDToString(agent.WorkspaceID), diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index 9a83ea6543..167cb1bb15 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -108,10 +108,10 @@ RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, c ` // Cancels every active task on the issue and returns the affected rows so the -// caller can reconcile each agent's status and broadcast task:cancelled -// events (#1587). Prior :exec form silently dropped that info, so internal -// cancel paths (issue status flips to cancelled/done, etc.) left agents stuck -// at status="working" with no self-correction. +// caller can reconcile each agent's status and broadcast task:cancelled events +// (#1587). Prior :exec form silently dropped that info, so internal cancel +// paths (issue status flips to cancelled/done, etc.) left agents stuck at +// status="working" with no self-correction. func (q *Queries) CancelAgentTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]AgentTaskQueue, error) { rows, err := q.db.Query(ctx, cancelAgentTasksByIssue, issueID) if err != nil { @@ -1218,6 +1218,46 @@ func (q *Queries) RecoverOrphanedTasksForRuntime(ctx context.Context, runtimeID return items, nil } +const refreshAgentStatusFromTasks = `-- name: RefreshAgentStatusFromTasks :one +UPDATE agent AS a +SET status = CASE WHEN EXISTS ( + SELECT 1 FROM agent_task_queue q + WHERE q.agent_id = a.id AND q.status IN ('dispatched', 'running') +) THEN 'working' ELSE 'idle' END, + updated_at = now() +WHERE a.id = $1 +RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, runtime_id, instructions, archived_at, archived_by, custom_env, custom_args, mcp_config, model +` + +func (q *Queries) RefreshAgentStatusFromTasks(ctx context.Context, id pgtype.UUID) (Agent, error) { + row := q.db.QueryRow(ctx, refreshAgentStatusFromTasks, id) + var i Agent + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.AvatarUrl, + &i.RuntimeMode, + &i.RuntimeConfig, + &i.Visibility, + &i.Status, + &i.MaxConcurrentTasks, + &i.OwnerID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Description, + &i.RuntimeID, + &i.Instructions, + &i.ArchivedAt, + &i.ArchivedBy, + &i.CustomEnv, + &i.CustomArgs, + &i.McpConfig, + &i.Model, + ) + return i, err +} + const restoreAgent = `-- name: RestoreAgent :one UPDATE agent SET archived_at = NULL, archived_by = NULL, updated_at = now() WHERE id = $1 diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index c4c12ad462..1e3cd4ab26 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -275,3 +275,13 @@ ORDER BY created_at DESC; UPDATE agent SET status = $2, updated_at = now() WHERE id = $1 RETURNING *; + +-- name: RefreshAgentStatusFromTasks :one +UPDATE agent AS a +SET status = CASE WHEN EXISTS ( + SELECT 1 FROM agent_task_queue q + WHERE q.agent_id = a.id AND q.status IN ('dispatched', 'running') +) THEN 'working' ELSE 'idle' END, + updated_at = now() +WHERE a.id = $1 +RETURNING *; From 04882c2201bffc02ad35f8d2998c0f368c47930d Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Mon, 27 Apr 2026 13:46:25 +0800 Subject: [PATCH 06/38] feat(labs): Add labs settings tab (#1732) --- .../views/settings/components/labs-tab.tsx | 30 +++++++++++++++++++ .../settings/components/settings-page.tsx | 5 +++- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 packages/views/settings/components/labs-tab.tsx diff --git a/packages/views/settings/components/labs-tab.tsx b/packages/views/settings/components/labs-tab.tsx new file mode 100644 index 0000000000..f91e9cdc2a --- /dev/null +++ b/packages/views/settings/components/labs-tab.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { FlaskConical } from "lucide-react"; +import { Card, CardContent } from "@multica/ui/components/ui/card"; + +export function LabsTab() { + return ( +
+
+

Labs

+ + + +
+
+ +
+
+

No experimental features yet

+

+ Beta features that require manual opt-in will appear here. +

+
+
+
+
+
+
+ ); +} diff --git a/packages/views/settings/components/settings-page.tsx b/packages/views/settings/components/settings-page.tsx index 9ce7b392ed..175315d894 100644 --- a/packages/views/settings/components/settings-page.tsx +++ b/packages/views/settings/components/settings-page.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react"; +import { User, Palette, Key, Settings, Users, FolderGit2, FlaskConical } from "lucide-react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs"; import { useCurrentWorkspace } from "@multica/core/paths"; import { AccountTab } from "./account-tab"; @@ -10,6 +10,7 @@ import { TokensTab } from "./tokens-tab"; import { WorkspaceTab } from "./workspace-tab"; import { MembersTab } from "./members-tab"; import { RepositoriesTab } from "./repositories-tab"; +import { LabsTab } from "./labs-tab"; const accountTabs = [ { value: "profile", label: "Profile", icon: User }, @@ -20,6 +21,7 @@ const accountTabs = [ const workspaceTabs = [ { value: "workspace", label: "General", icon: Settings }, { value: "repositories", label: "Repositories", icon: FolderGit2 }, + { value: "labs", label: "Labs", icon: FlaskConical }, { value: "members", label: "Members", icon: Users }, ]; @@ -82,6 +84,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) { + {extraAccountTabs?.map((tab) => ( {tab.content} From 2e7da8c63f4bb1e0256a14113719fe5f85392e22 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:11:16 +0800 Subject: [PATCH 07/38] fix(desktop): disable RPM build-id symlinks to avoid Slack conflict (#1734) Electron apps share an identical upstream Electron binary, so its GNU build-id is the same across every Electron RPM (Slack, VS Code, Discord, etc.). The default fpm/rpm behavior owns /usr/lib/.build-id/ symlinks, which collide between packages and make `dnf install` fail when any other Electron app is already installed. Pass `_build_id_links none` to rpmbuild via fpm so the multica-desktop RPM no longer claims those paths. Fixes multica-ai/multica#1723. --- apps/desktop/electron-builder.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 01354a3daa..2680789825 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -37,6 +37,14 @@ linux: - deb - rpm artifactName: multica-desktop-${version}-linux-${arch}.${ext} +rpm: + # Disable RPM build-id symlinks. Electron apps embed the upstream Electron + # binary, whose GNU build-id is identical across every app shipping the same + # Electron version (Slack, VS Code, Discord, ...). Without this, our RPM + # would own /usr/lib/.build-id/ paths and collide with any other + # Electron RPM already installed, breaking `dnf install` on Fedora/RHEL. + fpm: + - "--rpm-rpmbuild-define=_build_id_links none" win: target: - nsis From e9d04ecfc1a08a325440fbf202ae324fd662522c Mon Sep 17 00:00:00 2001 From: Ayman Alkurdi <110383762+Ayman-Alkurdi@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:23:42 +0300 Subject: [PATCH 08/38] feat(labels): ship issue labels (closes #1191) (#1233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labels): add issue label CRUD + attach/detach handlers (#1191) The issue_label and issue_to_label tables were scaffolded in 001_init.up.sql but never wired to any code path. This commit ships the backend for #1191: - Migration 048: adds created_at/updated_at timestamps + workspace-scoped case-insensitive unique index on label names - sqlc queries for label CRUD + issue<->label attach/detach + batch list (ListLabelsByIssueIDs for board/list views) - HTTP handlers: /api/labels CRUD, /api/issues/{id}/labels attach/detach - Protocol events: label:{created,updated,deleted} + issue_labels:changed - Handler tests covering CRUD, duplicate-name conflict, invalid-color, attach/detach idempotency, and cross-workspace isolation * feat(cli): add label and issue label subcommands (#1191) - multica label {list,get,create,update,delete} - multica issue label {list,add,remove} Both follow existing CLI conventions (JSON/table output, flag shapes) and exercise the /api/labels endpoints shipped in the previous commit. * feat(web): add labels UI — picker with inline create + management dialog (#1191) Exposes the backend label feature to users via the existing issue-detail sidebar. - `@multica/core/types/label` — Label, CreateLabelRequest, UpdateLabelRequest, plus response envelopes - `@multica/core/api/client` — 8 methods for label CRUD and issue↔label attach/detach - `@multica/core/labels` — labelKeys, queryOptions, and mutation hooks with optimistic updates (matches the project/ module layout) - WS event type literals extended for label:{created,updated,deleted} and issue_labels:changed - `views/labels/label-chip.tsx` — colored pill; uses relative luminance (ITU-R BT.601) to pick #111827 or #f9fafb text so chips stay readable on both pastel and saturated backgrounds - `views/issues/components/pickers/label-picker.tsx` - Multi-select combobox in the issue sidebar - When 0 labels: "Add label" trigger - When 1+ labels: the chips themselves are the trigger; × on each chip detaches without opening the picker - Inline create: typing a new name + Enter creates with a hash-derived color and attaches in one motion (matches Linear/GitHub) - "Manage labels…" footer opens a dialog containing the full workspace panel — users never leave the issue context to rename/recolor/delete - `views/issues/components/labels-panel.tsx` — workspace labels manager. Single-row create form (color swatch + name + Add button). Each label row supports inline rename + recolor + delete (with confirm dialog). Color input uses the browser's native picker for full-gamut access — no preset palette clutter. - `PropRow label="Labels"` added to the issue-detail sidebar below Project Labels are issue metadata everyone uses — not admin configuration. Putting them in Settings next to destructive workspace actions misframed them; adding a top-level nav entry or a sibling tab to the Issues page added surface area that wasn't earning its keep for a feature users touch occasionally. Keeping management in a dialog launched from the picker itself keeps users in their issue context and matches how GitHub handles label editing from the label selector. --- packages/core/api/client.ts | 49 ++ packages/core/labels/index.ts | 8 + packages/core/labels/mutations.ts | 131 ++++++ packages/core/labels/queries.ts | 28 ++ packages/core/package.json | 3 + packages/core/types/events.ts | 4 + packages/core/types/index.ts | 1 + packages/core/types/label.ts | 35 ++ packages/views/issues/components/index.ts | 2 +- .../views/issues/components/issue-detail.tsx | 5 +- .../views/issues/components/labels-panel.tsx | 290 ++++++++++++ .../views/issues/components/pickers/index.ts | 1 + .../components/pickers/label-picker.tsx | 228 +++++++++ .../components/pickers/property-picker.tsx | 16 +- packages/views/labels/index.ts | 1 + packages/views/labels/label-chip.tsx | 83 ++++ server/cmd/multica/cmd_issue_label.go | 148 ++++++ server/cmd/multica/cmd_label.go | 252 ++++++++++ server/cmd/multica/main.go | 2 + server/cmd/server/router.go | 14 + server/internal/handler/label.go | 421 +++++++++++++++++ server/internal/handler/label_test.go | 438 ++++++++++++++++++ .../migrations/059_label_timestamps.down.sql | 4 + server/migrations/059_label_timestamps.up.sql | 29 ++ server/pkg/db/generated/issue_label.sql.go | 247 ++++++++++ server/pkg/db/generated/models.go | 10 +- server/pkg/db/queries/issue_label.sql | 68 +++ server/pkg/protocol/events.go | 6 + 28 files changed, 2516 insertions(+), 8 deletions(-) create mode 100644 packages/core/labels/index.ts create mode 100644 packages/core/labels/mutations.ts create mode 100644 packages/core/labels/queries.ts create mode 100644 packages/core/types/label.ts create mode 100644 packages/views/issues/components/labels-panel.tsx create mode 100644 packages/views/issues/components/pickers/label-picker.tsx create mode 100644 packages/views/labels/index.ts create mode 100644 packages/views/labels/label-chip.tsx create mode 100644 server/cmd/multica/cmd_issue_label.go create mode 100644 server/cmd/multica/cmd_label.go create mode 100644 server/internal/handler/label.go create mode 100644 server/internal/handler/label_test.go create mode 100644 server/migrations/059_label_timestamps.down.sql create mode 100644 server/migrations/059_label_timestamps.up.sql create mode 100644 server/pkg/db/generated/issue_label.sql.go create mode 100644 server/pkg/db/queries/issue_label.sql diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index d68d1ea889..8822d4f917 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -51,6 +51,11 @@ import type { CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse, + Label, + CreateLabelRequest, + UpdateLabelRequest, + ListLabelsResponse, + IssueLabelsResponse, PinnedItem, CreatePinRequest, PinnedItemType, @@ -978,6 +983,50 @@ export class ApiClient { await this.fetch(`/api/projects/${id}`, { method: "DELETE" }); } + // Labels + async listLabels(): Promise { + return this.fetch(`/api/labels`); + } + + async getLabel(id: string): Promise
{projects.map((p) => ( onUpdate({ project_id: p.id })}> - {p.icon || "📁"} + {p.title} {p.id === projectId && } diff --git a/packages/views/projects/components/projects-page.tsx b/packages/views/projects/components/projects-page.tsx index 8f2647c445..3e5a3b9e32 100644 --- a/packages/views/projects/components/projects-page.tsx +++ b/packages/views/projects/components/projects-page.tsx @@ -36,6 +36,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types"; import { PageHeader } from "../../layout/page-header"; import { PriorityIcon } from "../../issues/components/priority-icon"; +import { ProjectIcon } from "./project-icon"; function formatRelativeDate(date: string): string { const diff = Date.now() - new Date(date).getTime(); @@ -77,7 +78,7 @@ function ProjectRow({ project }: { project: Project }) { href={wsPaths.projectDetail(project.id)} className="flex min-w-0 flex-1 items-center gap-2" > - {project.icon || "📁"} + {project.title} diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx index 97c6cc0d56..ba63b624ec 100644 --- a/packages/views/search/search-command.tsx +++ b/packages/views/search/search-command.tsx @@ -36,6 +36,7 @@ import type { WorkspacePaths } from "@multica/core/paths"; import { useModalStore } from "@multica/core/modals"; import { workspaceListOptions } from "@multica/core/workspace/queries"; import { StatusIcon } from "../issues/components"; +import { ProjectIcon } from "../projects/components/project-icon"; import { STATUS_CONFIG } from "@multica/core/issues/config"; import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config"; import type { ProjectStatus } from "@multica/core/types"; @@ -573,9 +574,7 @@ export function SearchCommand() { className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent" >
- - {project.icon || } - + From 66209975037b1ac8fcc4a5ddbdd4fc6efe204c52 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:33:34 +0800 Subject: [PATCH 10/38] feat(issues): render labels on list/board with bulk server-side fetch (#1741) * feat(issues): render labels on list/board with bulk server-side fetch ListIssues / ListOpenIssues / GetIssue now bulk-fetch labels per response via a new ListLabelsForIssues query so the client gets labels in a single round-trip instead of N requests per visible issue. List-row and board-card read issue.labels directly; an issue_labels:changed WS handler patches the list and detail caches in place so chips stay live across tabs, and attach/detach mutations mirror their result into the same caches for immediate same-tab feedback. Adds a "Labels" toggle to the card properties dropdown (defaults on). * fix(issues): preserve cached labels and refresh on label edit/delete Three fixes from gpt-boy's review of #1741: 1. IssueResponse.Labels was a non-omitempty slice, so paths that didn't load labels (UpdateIssue, batch updates, the issue:updated WS broadcast) serialized labels:null. onIssueUpdated then merged that null into the list/detail caches, wiping chips on every other tab whenever any non- label field changed. Switched to *[]LabelResponse + omitempty: nil = field absent (client merge keeps existing labels); non-nil (incl. empty slice) = authoritative. 2. issue.labels is a denormalized snapshot, but useUpdateLabel / useDeleteLabel and the WS label:* prefix only touched labelKeys, leaving stale chips in list/board after rename/recolor/delete. Mutations now also invalidate issueKeys.all(wsId), and the realtime refreshMap maps the label prefix to both labels and issues invalidation for cross-tab. 3. Persisted cardProperties from before this branch lacks the new `labels` key. Render fell back to `?? true` but the dropdown switch read it raw and showed unchecked. Added a custom Zustand merge that deep-merges cardProperties so newly added toggles inherit defaults for existing users; dropped the `?? true` fallbacks now that the store guarantees the key. --- packages/core/issues/stores/view-store.ts | 19 ++++++ packages/core/issues/ws-updaters.ts | 22 ++++++- packages/core/labels/mutations.ts | 26 ++++++-- packages/core/realtime/use-realtime-sync.ts | 23 ++++++- packages/core/types/events.ts | 6 ++ packages/core/types/issue.ts | 3 + .../views/issues/components/board-card.tsx | 10 ++- .../issues/components/issues-page.test.tsx | 2 +- packages/views/issues/components/list-row.tsx | 15 +++++ server/internal/handler/issue.go | 63 +++++++++++++++++++ server/pkg/db/generated/issue_label.sql.go | 55 ++++++++++++++++ server/pkg/db/queries/issue_label.sql | 11 ++++ 12 files changed, 246 insertions(+), 9 deletions(-) diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index e4a10c90aa..c26c0282d1 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -20,6 +20,7 @@ export interface CardProperties { dueDate: boolean; project: boolean; childProgress: boolean; + labels: boolean; } export interface ActorFilterValue { @@ -41,6 +42,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string } { key: "assignee", label: "Assignee" }, { key: "dueDate", label: "Due date" }, { key: "project", label: "Project" }, + { key: "labels", label: "Labels" }, { key: "childProgress", label: "Sub-issue progress" }, ]; @@ -92,6 +94,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue dueDate: true, project: true, childProgress: true, + labels: true, }, listCollapsedStatuses: [], @@ -204,6 +207,22 @@ export const viewStorePersistOptions = (name: string) => ({ cardProperties: state.cardProperties, listCollapsedStatuses: state.listCollapsedStatuses, }), + // Default Zustand merge is shallow, so a persisted `cardProperties` snapshot + // saved before a new toggle was introduced wins entirely and the new key is + // missing — the dropdown switch then reads `undefined` and renders unchecked + // even though defaults treat it as on. Deep-merge `cardProperties` so newly + // added toggles inherit their default value for existing users. + merge: (persisted: unknown, current: IssueViewState): IssueViewState => { + const p = (persisted ?? {}) as Partial; + return { + ...current, + ...p, + cardProperties: { + ...current.cardProperties, + ...(p.cardProperties ?? {}), + }, + }; + }, }); /** Factory: creates a vanilla StoreApi for use with React Context. */ diff --git a/packages/core/issues/ws-updaters.ts b/packages/core/issues/ws-updaters.ts index 6f55544d7d..299019fb11 100644 --- a/packages/core/issues/ws-updaters.ts +++ b/packages/core/issues/ws-updaters.ts @@ -6,7 +6,7 @@ import { patchIssueInBuckets, removeIssueFromBuckets, } from "./cache-helpers"; -import type { Issue } from "../types"; +import type { Issue, Label } from "../types"; import type { ListIssuesCache } from "../types"; export function onIssueCreated( @@ -72,6 +72,26 @@ export function onIssueUpdated( } } +/** + * Patch an issue's `labels` field in-place across the list cache, my-issues + * caches, and the detail cache. Triggered by the `issue_labels:changed` WS + * event after attach/detach so list/board chips update without a refetch. + */ +export function onIssueLabelsChanged( + qc: QueryClient, + wsId: string, + issueId: string, + labels: Label[], +) { + qc.setQueryData(issueKeys.list(wsId), (old) => + old ? patchIssueInBuckets(old, issueId, { labels }) : old, + ); + qc.setQueryData(issueKeys.detail(wsId, issueId), (old) => + old ? { ...old, labels } : old, + ); + qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); +} + export function onIssueDeleted( qc: QueryClient, wsId: string, diff --git a/packages/core/labels/mutations.ts b/packages/core/labels/mutations.ts index 8f6abc7935..354c72046f 100644 --- a/packages/core/labels/mutations.ts +++ b/packages/core/labels/mutations.ts @@ -2,6 +2,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api"; import { labelKeys } from "./queries"; import { useWorkspaceId } from "../hooks"; +import { issueKeys } from "../issues/queries"; +import { onIssueLabelsChanged } from "../issues/ws-updaters"; import type { Label, CreateLabelRequest, @@ -60,6 +62,9 @@ export function useUpdateLabel() { // stale copy of this label is refetched. The list cache is the source // of truth; byIssue views will re-render with the fresh data. qc.invalidateQueries({ queryKey: labelKeys.all(wsId) }); + // Issues now embed labels (denormalized snapshot), so a rename/recolor + // also has to refresh the issues caches that hold those snapshots. + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); }, }); } @@ -84,6 +89,9 @@ export function useDeleteLabel() { }, onSettled: () => { qc.invalidateQueries({ queryKey: labelKeys.all(wsId) }); + // A deleted label still lives in cached issue.labels arrays until we + // refetch — invalidate so list/board chips drop the orphan. + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); }, }); } @@ -100,6 +108,9 @@ export function useAttachLabel(issueId: string) { // invalidation to refetch. if (data && Array.isArray(data.labels)) { qc.setQueryData(labelKeys.byIssue(wsId, issueId), data); + // Mirror into the issues list / detail caches so list/board chips + // update immediately for the actor without waiting for the WS event. + onIssueLabelsChanged(qc, wsId, issueId, data.labels); } }, onSettled: () => { @@ -116,13 +127,20 @@ export function useDetachLabel(issueId: string) { onMutate: async (labelId) => { await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) }); const prev = qc.getQueryData(labelKeys.byIssue(wsId, issueId)); - qc.setQueryData(labelKeys.byIssue(wsId, issueId), (old) => - old ? { ...old, labels: old.labels.filter((l: Label) => l.id !== labelId) } : old, - ); + const next = prev + ? { ...prev, labels: prev.labels.filter((l: Label) => l.id !== labelId) } + : undefined; + if (next) { + qc.setQueryData(labelKeys.byIssue(wsId, issueId), next); + onIssueLabelsChanged(qc, wsId, issueId, next.labels); + } return { prev }; }, onError: (_err, _id, ctx) => { - if (ctx?.prev) qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev); + if (ctx?.prev) { + qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev); + onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels); + } }, onSettled: () => { qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) }); diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index e560f8fab6..d6ed19c38a 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -18,6 +18,7 @@ import { onIssueCreated, onIssueUpdated, onIssueDeleted, + onIssueLabelsChanged, } from "../issues/ws-updaters"; import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters"; import { inboxKeys } from "../inbox/queries"; @@ -31,6 +32,7 @@ import type { IssueUpdatedPayload, IssueCreatedPayload, IssueDeletedPayload, + IssueLabelsChangedPayload, InboxNewPayload, CommentCreatedPayload, CommentUpdatedPayload, @@ -117,6 +119,17 @@ export function useRealtimeSync( const wsId = getCurrentWsId(); if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) }); }, + label: () => { + // label:created/updated/deleted — also refresh issues, since each + // issue carries a denormalized snapshot of its labels (rename/recolor + // /delete on a label needs to flush the chips on every issue showing + // it). + const wsId = getCurrentWsId(); + if (wsId) { + qc.invalidateQueries({ queryKey: ["labels", wsId] }); + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + } + }, pin: () => { const wsId = getCurrentWsId(); const userId = authStore.getState().user?.id; @@ -147,7 +160,7 @@ export function useRealtimeSync( // Event types handled by specific handlers below -- skip generic refresh const specificEvents = new Set([ - "issue:updated", "issue:created", "issue:deleted", "inbox:new", + "issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new", "comment:created", "comment:updated", "comment:deleted", "activity:created", "reaction:added", "reaction:removed", @@ -200,6 +213,13 @@ export function useRealtimeSync( } }); + const unsubIssueLabelsChanged = ws.on("issue_labels:changed", (p) => { + const { issue_id, labels } = p as IssueLabelsChangedPayload; + if (!issue_id) return; + const wsId = getCurrentWsId(); + if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []); + }); + const unsubInboxNew = ws.on("inbox:new", (p) => { const { item } = p as InboxNewPayload; if (!item) return; @@ -464,6 +484,7 @@ export function useRealtimeSync( unsubIssueUpdated(); unsubIssueCreated(); unsubIssueDeleted(); + unsubIssueLabelsChanged(); unsubInboxNew(); unsubCommentCreated(); unsubCommentUpdated(); diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index deb06bf0ad..d4660c75b4 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -5,6 +5,7 @@ import type { Comment, Reaction } from "./comment"; import type { TimelineEntry } from "./activity"; import type { Workspace, MemberWithUser, Invitation } from "./workspace"; import type { Project } from "./project"; +import type { Label } from "./label"; // WebSocket event types (matching Go server protocol/events.go) export type WSEventType = @@ -82,6 +83,11 @@ export interface IssueDeletedPayload { issue_id: string; } +export interface IssueLabelsChangedPayload { + issue_id: string; + labels: Label[]; +} + export interface AgentStatusPayload { agent: Agent; } diff --git a/packages/core/types/issue.ts b/packages/core/types/issue.ts index 5625274d94..ba30d69be9 100644 --- a/packages/core/types/issue.ts +++ b/packages/core/types/issue.ts @@ -1,3 +1,5 @@ +import type { Label } from "./label"; + export type IssueStatus = | "backlog" | "todo" @@ -38,6 +40,7 @@ export interface Issue { position: number; due_date: string | null; reactions?: IssueReaction[]; + labels?: Label[]; created_at: string; updated_at: string; } diff --git a/packages/views/issues/components/board-card.tsx b/packages/views/issues/components/board-card.tsx index 7a940a5a90..b0a39ef3b2 100644 --- a/packages/views/issues/components/board-card.tsx +++ b/packages/views/issues/components/board-card.tsx @@ -22,6 +22,7 @@ import { useViewStore } from "@multica/core/issues/stores/view-store-context"; import { ProgressRing } from "./progress-ring"; import type { ChildProgress } from "./list-row"; import { IssueActionsContextMenu } from "../actions"; +import { LabelChip } from "../../labels/label-chip"; function formatDate(date: string): string { return new Date(date).toLocaleDateString("en-US", { @@ -60,6 +61,7 @@ export const BoardCardContent = memo(function BoardCardContent({ enabled: storeProperties.project && !!issue.project_id, }); const project = issue.project_id ? projects.find((p) => p.id === issue.project_id) : undefined; + const labels = issue.labels ?? []; const updateIssueMutation = useUpdateIssue(); const handleUpdate = useCallback( @@ -78,6 +80,7 @@ export const BoardCardContent = memo(function BoardCardContent({ const showDueDate = storeProperties.dueDate && issue.due_date; const showProject = storeProperties.project && project; const showChildProgress = storeProperties.childProgress && childProgress; + const showLabels = storeProperties.labels && labels.length > 0; return (
@@ -89,8 +92,8 @@ export const BoardCardContent = memo(function BoardCardContent({ {issue.title}

- {/* Sub-issue progress + project */} - {(showChildProgress || showProject) && ( + {/* Sub-issue progress + project + labels */} + {(showChildProgress || showProject || showLabels) && (
{showChildProgress && (
@@ -106,6 +109,9 @@ export const BoardCardContent = memo(function BoardCardContent({ {project!.title} )} + {showLabels && labels.map((label) => ( + + ))}
)} diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index e91edaf766..57ac06fceb 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -108,7 +108,7 @@ const mockViewState = { includeNoProject: false, sortBy: "position" as const, sortDirection: "asc" as const, - cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true }, + cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true }, listCollapsedStatuses: [] as string[], setViewMode: vi.fn(), toggleStatusFilter: vi.fn(), diff --git a/packages/views/issues/components/list-row.tsx b/packages/views/issues/components/list-row.tsx index 54d63b3164..af70389216 100644 --- a/packages/views/issues/components/list-row.tsx +++ b/packages/views/issues/components/list-row.tsx @@ -14,6 +14,7 @@ import { ProjectIcon } from "../../projects/components/project-icon"; import { PriorityIcon } from "./priority-icon"; import { ProgressRing } from "./progress-ring"; import { IssueActionsContextMenu } from "../actions"; +import { LabelChip } from "../../labels/label-chip"; export interface ChildProgress { done: number; @@ -44,11 +45,13 @@ export const ListRow = memo(function ListRow({ enabled: storeProperties.project && !!issue.project_id, }); const project = issue.project_id ? projects.find((pr) => pr.id === issue.project_id) : undefined; + const labels = issue.labels ?? []; const showProject = storeProperties.project && project; const showChildProgress = storeProperties.childProgress && childProgress; const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id; const showDueDate = storeProperties.dueDate && issue.due_date; + const showLabels = storeProperties.labels && labels.length > 0; return ( @@ -89,6 +92,18 @@ export const ListRow = memo(function ListRow({ )} + {showLabels && ( + + {labels.slice(0, 3).map((label) => ( + + ))} + {labels.length > 3 && ( + + +{labels.length - 3} + + )} + + )} {showProject && ( diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index cee39fddb8..9fdb10f8f0 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -42,6 +42,13 @@ type IssueResponse struct { UpdatedAt string `json:"updated_at"` Reactions []IssueReactionResponse `json:"reactions,omitempty"` Attachments []AttachmentResponse `json:"attachments,omitempty"` + // Labels are bulk-attached by list/detail endpoints so the client can render + // chips without an N+1 round-trip per row. Pointer + omitempty so paths that + // don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated + // WS broadcast) emit no `labels` field at all — the client merge then + // preserves whatever labels are already in cache. nil pointer = "field + // absent, do not touch"; non-nil (incl. empty slice) = authoritative list. + Labels *[]LabelResponse `json:"labels,omitempty"` } func issueToResponse(i db.Issue, issuePrefix string) IssueResponse { @@ -93,6 +100,37 @@ func issueListRowToResponse(i db.ListIssuesRow, issuePrefix string) IssueRespons } } +// labelsByIssue bulk-loads labels for the given issue IDs and returns a map +// keyed by issue UUID string. On error or empty input, returns an empty map — +// label rendering is non-critical and we'd rather serve issues without labels +// than fail the whole list call. +func (h *Handler) labelsByIssue(ctx context.Context, wsUUID pgtype.UUID, issueIDs []pgtype.UUID) map[string][]LabelResponse { + out := map[string][]LabelResponse{} + if len(issueIDs) == 0 { + return out + } + rows, err := h.Queries.ListLabelsForIssues(ctx, db.ListLabelsForIssuesParams{ + IssueIds: issueIDs, + WorkspaceID: wsUUID, + }) + if err != nil { + slog.Warn("ListLabelsForIssues failed", "error", err) + return out + } + for _, r := range rows { + issueID := uuidToString(r.IssueID) + out[issueID] = append(out[issueID], LabelResponse{ + ID: uuidToString(r.ID), + WorkspaceID: uuidToString(r.WorkspaceID), + Name: r.Name, + Color: r.Color, + CreatedAt: timestampToString(r.CreatedAt), + UpdatedAt: timestampToString(r.UpdatedAt), + }) + } + return out +} + func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueResponse { identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number)) return IssueResponse{ @@ -603,9 +641,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } prefix := h.getIssuePrefix(ctx, wsUUID) + ids := make([]pgtype.UUID, len(issues)) + for i, issue := range issues { + ids[i] = issue.ID + } + labelsMap := h.labelsByIssue(ctx, wsUUID, ids) resp := make([]IssueResponse, len(issues)) for i, issue := range issues { resp[i] = openIssueRowToResponse(issue, prefix) + labels := labelsMap[resp[i].ID] + if labels == nil { + labels = []LabelResponse{} + } + resp[i].Labels = &labels } writeJSON(w, http.StatusOK, map[string]any{ @@ -664,9 +712,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } prefix := h.getIssuePrefix(ctx, wsUUID) + ids := make([]pgtype.UUID, len(issues)) + for i, issue := range issues { + ids[i] = issue.ID + } + labelsMap := h.labelsByIssue(ctx, wsUUID, ids) resp := make([]IssueResponse, len(issues)) for i, issue := range issues { resp[i] = issueListRowToResponse(issue, prefix) + labels := labelsMap[resp[i].ID] + if labels == nil { + labels = []LabelResponse{} + } + resp[i].Labels = &labels } writeJSON(w, http.StatusOK, map[string]any{ @@ -683,6 +741,11 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) { } prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) resp := issueToResponse(issue, prefix) + detailLabels := h.labelsByIssue(r.Context(), issue.WorkspaceID, []pgtype.UUID{issue.ID})[uuidToString(issue.ID)] + if detailLabels == nil { + detailLabels = []LabelResponse{} + } + resp.Labels = &detailLabels // Fetch issue reactions. reactions, err := h.Queries.ListIssueReactions(r.Context(), issue.ID) diff --git a/server/pkg/db/generated/issue_label.sql.go b/server/pkg/db/generated/issue_label.sql.go index d58758edbd..54134cf9c3 100644 --- a/server/pkg/db/generated/issue_label.sql.go +++ b/server/pkg/db/generated/issue_label.sql.go @@ -211,6 +211,61 @@ func (q *Queries) ListLabelsByIssue(ctx context.Context, arg ListLabelsByIssuePa return items, nil } +const listLabelsForIssues = `-- name: ListLabelsForIssues :many +SELECT il.issue_id, l.id, l.workspace_id, l.name, l.color, l.created_at, l.updated_at +FROM issue_label l +JOIN issue_to_label il ON il.label_id = l.id +WHERE il.issue_id = ANY($1::uuid[]) + AND l.workspace_id = $2::uuid +ORDER BY il.issue_id, LOWER(l.name) ASC +` + +type ListLabelsForIssuesParams struct { + IssueIds []pgtype.UUID `json:"issue_ids"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +type ListLabelsForIssuesRow struct { + IssueID pgtype.UUID `json:"issue_id"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Name string `json:"name"` + Color string `json:"color"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +// Bulk variant: fetch labels for many issues in one round-trip so the issue +// list endpoints can fold labels into each row without N+1 queries from the +// client. Workspace-guarded the same way as ListLabelsByIssue. +func (q *Queries) ListLabelsForIssues(ctx context.Context, arg ListLabelsForIssuesParams) ([]ListLabelsForIssuesRow, error) { + rows, err := q.db.Query(ctx, listLabelsForIssues, arg.IssueIds, arg.WorkspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListLabelsForIssuesRow{} + for rows.Next() { + var i ListLabelsForIssuesRow + if err := rows.Scan( + &i.IssueID, + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Color, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateLabel = `-- name: UpdateLabel :one UPDATE issue_label SET name = COALESCE($3, name), diff --git a/server/pkg/db/queries/issue_label.sql b/server/pkg/db/queries/issue_label.sql index 13036d32c0..0469c02476 100644 --- a/server/pkg/db/queries/issue_label.sql +++ b/server/pkg/db/queries/issue_label.sql @@ -66,3 +66,14 @@ JOIN issue_to_label il ON il.label_id = l.id WHERE il.issue_id = sqlc.arg('issue_id')::uuid AND l.workspace_id = sqlc.arg('workspace_id')::uuid ORDER BY LOWER(l.name) ASC; + +-- name: ListLabelsForIssues :many +-- Bulk variant: fetch labels for many issues in one round-trip so the issue +-- list endpoints can fold labels into each row without N+1 queries from the +-- client. Workspace-guarded the same way as ListLabelsByIssue. +SELECT il.issue_id, l.* +FROM issue_label l +JOIN issue_to_label il ON il.label_id = l.id +WHERE il.issue_id = ANY(sqlc.arg('issue_ids')::uuid[]) + AND l.workspace_id = sqlc.arg('workspace_id')::uuid +ORDER BY il.issue_id, LOWER(l.name) ASC; From bf6509be962e7a2c60017a44bffa2476451c9047 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:50:13 +0800 Subject: [PATCH 11/38] fix(issues): show labels in my-issues view + place chips after title (#1743) - my-issues page lost labels because myIssuesViewStore cherry-picked name/storage/partialize from viewStorePersistOptions and dropped the cardProperties-aware merge. Persisted snapshots predating the labels toggle had cardProperties.labels = undefined, falsy-shorting the chip render. Extracted mergeViewStatePersisted as a generic and wired it into both stores. - list-row chips now render right after the title (with a small left margin for breathing room) instead of in the right-aligned cluster. --- .../issues/stores/my-issues-view-store.ts | 6 ++++ packages/core/issues/stores/view-store.ts | 32 ++++++++++++------- .../issues/components/issues-page.test.tsx | 2 ++ packages/views/issues/components/list-row.tsx | 24 +++++++------- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/core/issues/stores/my-issues-view-store.ts b/packages/core/issues/stores/my-issues-view-store.ts index 9ffb373c1f..766f41388a 100644 --- a/packages/core/issues/stores/my-issues-view-store.ts +++ b/packages/core/issues/stores/my-issues-view-store.ts @@ -6,6 +6,7 @@ import { type IssueViewState, viewStoreSlice, viewStorePersistOptions, + mergeViewStatePersisted, } from "./view-store"; import { registerForWorkspaceRehydration } from "../../platform/workspace-storage"; @@ -32,6 +33,11 @@ const _myIssuesViewStore = createStore()( ...basePersist.partialize(state), scope: state.scope, }), + // Reuse the same deep-merge as the base view store so newly added + // cardProperties toggles inherit defaults for existing users. Without + // this, the my-issues page renders no labels because the persisted + // snapshot predates the `labels` key and shallow-merge wins. + merge: mergeViewStatePersisted, }, ), ); diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index c26c0282d1..c704ba62b4 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -212,19 +212,29 @@ export const viewStorePersistOptions = (name: string) => ({ // missing — the dropdown switch then reads `undefined` and renders unchecked // even though defaults treat it as on. Deep-merge `cardProperties` so newly // added toggles inherit their default value for existing users. - merge: (persisted: unknown, current: IssueViewState): IssueViewState => { - const p = (persisted ?? {}) as Partial; - return { - ...current, - ...p, - cardProperties: { - ...current.cardProperties, - ...(p.cardProperties ?? {}), - }, - }; - }, + merge: mergeViewStatePersisted, }); +/** + * Reusable persist `merge` for view-state stores. Generic over T so the same + * deep-merge for `cardProperties` works for both the issues view store and + * the my-issues view store (which extends IssueViewState). + */ +export function mergeViewStatePersisted( + persisted: unknown, + current: T, +): T { + const p = (persisted ?? {}) as Partial; + return { + ...current, + ...p, + cardProperties: { + ...current.cardProperties, + ...(p.cardProperties ?? {}), + }, + }; +} + /** Factory: creates a vanilla StoreApi for use with React Context. */ export function createIssueViewStore(persistKey: string): StoreApi { const store = createStore()( diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index 57ac06fceb..d543443c04 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -130,6 +130,7 @@ const mockViewState = { vi.mock("@multica/core/issues/stores/view-store", () => ({ useClearFiltersOnWorkspaceChange: () => {}, viewStorePersistOptions: () => ({ name: "test", storage: undefined, partialize: (s: any) => s }), + mergeViewStatePersisted: (_p: unknown, c: any) => c, viewStoreSlice: vi.fn(), useIssueViewStore: Object.assign( (selector?: any) => (selector ? selector(mockViewState) : mockViewState), @@ -153,6 +154,7 @@ vi.mock("@multica/core/issues/stores/view-store", () => ({ { key: "assignee", label: "Assignee" }, { key: "dueDate", label: "Due date" }, { key: "project", label: "Project" }, + { key: "labels", label: "Labels" }, { key: "childProgress", label: "Sub-issue progress" }, ], })); diff --git a/packages/views/issues/components/list-row.tsx b/packages/views/issues/components/list-row.tsx index af70389216..8dd3b81b01 100644 --- a/packages/views/issues/components/list-row.tsx +++ b/packages/views/issues/components/list-row.tsx @@ -91,19 +91,19 @@ export const ListRow = memo(function ListRow({ )} + {showLabels && ( + + {labels.slice(0, 3).map((label) => ( + + ))} + {labels.length > 3 && ( + + +{labels.length - 3} + + )} + + )} - {showLabels && ( - - {labels.slice(0, 3).map((label) => ( - - ))} - {labels.length > 3 && ( - - +{labels.length - 3} - - )} - - )} {showProject && ( From d14265de2abe8625742ea73772c05f10ecbeadc2 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:17:34 +0800 Subject: [PATCH 12/38] fix(comments): preserve newlines from agent CLI writes (#1744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(comments): preserve newlines from agent CLI writes Agents (e.g. Codex) routinely emit `multica issue comment add --content "para1\n\npara2"` because Python/JSON-style string literals are their default. Bash does not expand `\n` inside double quotes, so the literal 4-char sequence flowed through the CLI into the database and rendered as text in the issue panel — comments came out as one wall of prose. Three coordinated fixes so the platform behavior no longer depends on whether a given model has strong bash-quoting intuition: - CLI: decode `\n / \r / \t / \\` in `--content` and `--description` for `issue create / update / comment add` (callers needing a literal backslash still have `--content-stdin`). - Agent prompt: rewrite the comment-add example in the injected runtime config to require `--content-stdin` + HEREDOC for any multi-line body, and call out the same rule for `--description`. The previous wording flagged stdin only for "backticks, quotes", which models read as irrelevant to plain paragraphs. - Renderer: add `remark-breaks` to the shared Markdown plugin chain so a bare `\n` becomes a visible line break instead of a CommonMark soft break — protects against models that emit single newlines for formatting. Tests: pin the new CLI helper, and pin the runtime-config guidance so the multi-line wording cannot decay back into a footnote. * fix(comments): address review feedback on newline-rendering PR - Cover the issue panel: ReadonlyContent (used by every comment card and the issue description) has its own react-markdown wiring; add remark-breaks there too so the renderer fix actually applies to the surface the bug was reported on, not just the chat panel. Pinned by ReadonlyContent line-break tests. - Make the prompt's `--description` guidance executable: add `--description-stdin` to `issue create` / `issue update`, refactor comment-add to share a single `resolveTextFlag` helper, and have the injected runtime config name the real flag instead of an imaginary "stdin / a tempfile" path. Pinned by the runtime-config guidance test. - Document the unescape contract on each affected flag's help text and pin the precise boundary in tests: `\n / \r / \t / \\` are decoded; `\d / \w / \s / \u / \0` and other unrecognised escapes pass through verbatim, so regex literals and Windows paths survive intact unless they embed a literal `\n` / `\r` / `\t`. Callers that need the literal sequence have `--content-stdin` / `--description-stdin` as the escape hatch. --- packages/ui/markdown/Markdown.tsx | 3 +- packages/ui/package.json | 1 + .../views/editor/readonly-content.test.tsx | 17 +++ packages/views/editor/readonly-content.tsx | 3 +- packages/views/package.json | 1 + pnpm-lock.yaml | 23 +++ server/cmd/multica/cmd_issue.go | 122 ++++++++++++---- server/cmd/multica/cmd_issue_test.go | 133 ++++++++++++++++++ .../internal/daemon/execenv/execenv_test.go | 33 +++++ .../internal/daemon/execenv/runtime_config.go | 12 +- 10 files changed, 318 insertions(+), 30 deletions(-) diff --git a/packages/ui/markdown/Markdown.tsx b/packages/ui/markdown/Markdown.tsx index c6f2b0c651..4b5c0b8d84 100644 --- a/packages/ui/markdown/Markdown.tsx +++ b/packages/ui/markdown/Markdown.tsx @@ -3,6 +3,7 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd import rehypeKatex from 'rehype-katex' import rehypeRaw from 'rehype-raw' import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' +import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { FileText, Download } from 'lucide-react' @@ -404,7 +405,7 @@ export function Markdown({ return (
{ expect(text).toContain("\\int_0^1 x^2 \\, dx"); }); }); + +describe("ReadonlyContent line breaks", () => { + // Issue panel comments are the primary user-visible surface for agent + // output. CommonMark's default soft-break behavior collapses single + // newlines into spaces; agent text often relies on a single newline as a + // visible break. remark-breaks must remain wired into ReadonlyContent's + // remark plugin chain or comments lose their formatting again. + it("converts a single newline into a
", () => { + const { container } = render(); + expect(container.querySelector("br")).not.toBeNull(); + }); + + it("renders a blank-line gap as separate paragraphs", () => { + const { container } = render(); + expect(container.querySelectorAll("p").length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/views/editor/readonly-content.tsx b/packages/views/editor/readonly-content.tsx index 52674faa6a..f3db6fab0b 100644 --- a/packages/views/editor/readonly-content.tsx +++ b/packages/views/editor/readonly-content.tsx @@ -22,6 +22,7 @@ import ReactMarkdown, { type Components, } from "react-markdown"; import rehypeKatex from "rehype-katex"; +import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import rehypeRaw from "rehype-raw"; @@ -297,7 +298,7 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) { return (
` flag value and a paired +// `---stdin` flag, mirroring the existing `--content` / `--content-stdin` +// pattern. It returns the resolved string and an error when both are set or +// stdin is requested but produces no body. The resulting text is returned +// verbatim — callers decide whether to apply unescapeFlagText to the inline +// flag form (and never to the stdin form, which already preserves literal +// backslashes). +func resolveTextFlag(cmd *cobra.Command, flagName string) (string, bool, error) { + stdinFlag := flagName + "-stdin" + useStdin, _ := cmd.Flags().GetBool(stdinFlag) + inline, _ := cmd.Flags().GetString(flagName) + if useStdin && inline != "" { + return "", false, fmt.Errorf("--%s and --%s are mutually exclusive", flagName, stdinFlag) + } + if useStdin { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", false, fmt.Errorf("read stdin for --%s: %w", stdinFlag, err) + } + body := strings.TrimSuffix(string(data), "\n") + if body == "" { + return "", false, fmt.Errorf("stdin content for --%s is empty", stdinFlag) + } + return body, true, nil + } + if inline == "" { + return "", false, nil + } + return unescapeFlagText(inline), true, nil +} + +// unescapeFlagText decodes the common backslash escape sequences (\n, \r, \t, +// \\) in a free-form string flag value. Shells like bash do not expand these +// inside double quotes, so an LLM agent that emits +// `--content "para1\n\npara2"` ends up sending the literal 4-char sequence to +// the CLI and then to storage, where it renders as text rather than as line +// breaks. Decoding here makes the flag behave the way callers intuit; users +// who genuinely need a literal backslash-n can write `\\n` or pipe the body +// via `--content-stdin` / `--description-stdin`, which bypass this path +// entirely. +func unescapeFlagText(s string) string { + if !strings.ContainsRune(s, '\\') { + return s + } + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c == '\\' && i+1 < len(s) { + switch s[i+1] { + case 'n': + b.WriteByte('\n') + i++ + continue + case 'r': + b.WriteByte('\r') + i++ + continue + case 't': + b.WriteByte('\t') + i++ + continue + case '\\': + b.WriteByte('\\') + i++ + continue + } + } + b.WriteByte(c) + } + return b.String() +} + var issueCmd = &cobra.Command{ Use: "issue", Short: "Work with issues", @@ -186,7 +259,8 @@ func init() { // issue create issueCreateCmd.Flags().String("title", "", "Issue title (required)") - issueCreateCmd.Flags().String("description", "", "Issue description") + issueCreateCmd.Flags().String("description", "", "Issue description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)") + issueCreateCmd.Flags().Bool("description-stdin", false, "Read issue description from stdin (preserves multi-line content verbatim)") issueCreateCmd.Flags().String("status", "", "Issue status") issueCreateCmd.Flags().String("priority", "", "Issue priority") issueCreateCmd.Flags().String("assignee", "", "Assignee name (member or agent)") @@ -198,7 +272,8 @@ func init() { // issue update issueUpdateCmd.Flags().String("title", "", "New title") - issueUpdateCmd.Flags().String("description", "", "New description") + issueUpdateCmd.Flags().String("description", "", "New description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)") + issueUpdateCmd.Flags().Bool("description-stdin", false, "Read new description from stdin (preserves multi-line content verbatim)") issueUpdateCmd.Flags().String("status", "", "New status") issueUpdateCmd.Flags().String("priority", "", "New priority") issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent)") @@ -232,8 +307,8 @@ func init() { issueRunMessagesCmd.Flags().Int("since", 0, "Only return messages after this sequence number") // issue comment add - issueCommentAddCmd.Flags().String("content", "", "Comment content (required unless --content-stdin)") - issueCommentAddCmd.Flags().Bool("content-stdin", false, "Read comment content from stdin (avoids shell escaping issues)") + issueCommentAddCmd.Flags().String("content", "", "Comment content (decodes \\n, \\r, \\t, \\\\; pipe via --content-stdin for multi-line bodies or to preserve literal backslashes)") + issueCommentAddCmd.Flags().Bool("content-stdin", false, "Read comment content from stdin (preserves multi-line content verbatim)") issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)") issueCommentAddCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)") issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json") @@ -411,8 +486,12 @@ func runIssueCreate(cmd *cobra.Command, _ []string) error { defer cancel() body := map[string]any{"title": title} - if v, _ := cmd.Flags().GetString("description"); v != "" { - body["description"] = v + desc, hasDesc, err := resolveTextFlag(cmd, "description") + if err != nil { + return err + } + if hasDesc { + body["description"] = desc } if v, _ := cmd.Flags().GetString("status"); v != "" { body["status"] = v @@ -486,9 +565,12 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error { v, _ := cmd.Flags().GetString("title") body["title"] = v } - if cmd.Flags().Changed("description") { - v, _ := cmd.Flags().GetString("description") - body["description"] = v + if cmd.Flags().Changed("description") || cmd.Flags().Changed("description-stdin") { + desc, _, err := resolveTextFlag(cmd, "description") + if err != nil { + return err + } + body["description"] = desc } if cmd.Flags().Changed("status") { v, _ := cmd.Flags().GetString("status") @@ -717,25 +799,11 @@ func runIssueCommentList(cmd *cobra.Command, args []string) error { } func runIssueCommentAdd(cmd *cobra.Command, args []string) error { - content, _ := cmd.Flags().GetString("content") - useStdin, _ := cmd.Flags().GetBool("content-stdin") - - if content != "" && useStdin { - return fmt.Errorf("--content and --content-stdin are mutually exclusive") + content, hasContent, err := resolveTextFlag(cmd, "content") + if err != nil { + return err } - - if useStdin { - data, err := io.ReadAll(os.Stdin) - if err != nil { - return fmt.Errorf("read stdin: %w", err) - } - content = strings.TrimSuffix(string(data), "\n") - if content == "" { - return fmt.Errorf("stdin content is empty") - } - } - - if content == "" { + if !hasContent { return fmt.Errorf("--content or --content-stdin is required") } diff --git a/server/cmd/multica/cmd_issue_test.go b/server/cmd/multica/cmd_issue_test.go index e0535d08c1..d3aa12d143 100644 --- a/server/cmd/multica/cmd_issue_test.go +++ b/server/cmd/multica/cmd_issue_test.go @@ -5,12 +5,145 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "strings" "testing" + "github.com/spf13/cobra" + "github.com/multica-ai/multica/server/internal/cli" ) +// pipeStdin replaces os.Stdin with a pipe seeded by the given body for the +// duration of fn, so resolveTextFlag's --content-stdin / --description-stdin +// branch can be exercised in unit tests without spawning a subprocess. +func pipeStdin(t *testing.T, body string, fn func()) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + if _, err := w.WriteString(body); err != nil { + t.Fatalf("write pipe: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close pipe writer: %v", err) + } + orig := os.Stdin + os.Stdin = r + defer func() { + os.Stdin = orig + _ = r.Close() + }() + fn() +} + +// newFlagTestCmd builds a throwaway cobra.Command carrying the inline + +// stdin flag pair that resolveTextFlag expects. +func newFlagTestCmd(name string) *cobra.Command { + c := &cobra.Command{Use: "test"} + c.Flags().String(name, "", "") + c.Flags().Bool(name+"-stdin", false, "") + return c +} + +func TestResolveTextFlag(t *testing.T) { + t.Run("inline value is unescaped", func(t *testing.T) { + c := newFlagTestCmd("description") + _ = c.Flags().Set("description", `para1\n\npara2`) + got, ok, err := resolveTextFlag(c, "description") + if err != nil || !ok { + t.Fatalf("unexpected: ok=%v err=%v", ok, err) + } + if got != "para1\n\npara2" { + t.Errorf("got %q, want decoded paragraphs", got) + } + }) + + t.Run("stdin body is preserved verbatim", func(t *testing.T) { + c := newFlagTestCmd("description") + _ = c.Flags().Set("description-stdin", "true") + body := "first line\nsecond line with a literal \\n in it\n" + pipeStdin(t, body, func() { + got, ok, err := resolveTextFlag(c, "description") + if err != nil || !ok { + t.Fatalf("unexpected: ok=%v err=%v", ok, err) + } + // strings.TrimSuffix one trailing newline like content-stdin. + want := "first line\nsecond line with a literal \\n in it" + if got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + }) + + t.Run("inline + stdin is rejected", func(t *testing.T) { + c := newFlagTestCmd("description") + _ = c.Flags().Set("description", "inline") + _ = c.Flags().Set("description-stdin", "true") + if _, _, err := resolveTextFlag(c, "description"); err == nil { + t.Fatalf("expected mutually-exclusive error") + } + }) + + t.Run("missing both returns hasValue=false", func(t *testing.T) { + c := newFlagTestCmd("description") + got, ok, err := resolveTextFlag(c, "description") + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if ok || got != "" { + t.Errorf("expected absent flag to yield (\"\", false), got (%q, %v)", got, ok) + } + }) +} + +func TestUnescapeFlagText(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"no escapes", "hello world", "hello world"}, + {"single newline", `line1\nline2`, "line1\nline2"}, + {"double newline becomes paragraph", `para1\n\npara2`, "para1\n\npara2"}, + {"tab and carriage return", `a\tb\rc`, "a\tb\rc"}, + {"escaped backslash preserved as literal", `keep\\nliteral`, `keep\nliteral`}, + {"trailing lone backslash kept verbatim", `tail\`, `tail\`}, + {"unknown escape kept verbatim", `\x not touched`, `\x not touched`}, + {"mixed real and escaped newlines", "real\n" + `and\nescaped`, "real\nand\nescaped"}, + {"unicode untouched", `中文段落\n下一段`, "中文段落\n下一段"}, + // Contract boundary: only \n \r \t \\ are decoded. Common regex / + // path / formatter escape sequences such as \d, \w, \s, \u, \0 must + // pass through verbatim — this lets users paste regex snippets or + // printf-style format strings into --content without surprise + // mutation. Anyone who genuinely wants the literal characters \\n + // can either double the backslash or pipe the body via stdin. + {"regex digit class untouched", `\d+\s*\w+`, `\d+\s*\w+`}, + {"unicode escape untouched", `café`, `café`}, + {"null escape untouched", `\0 sentinel`, `\0 sentinel`}, + {"windows path no special chars", `C:\Users\bob`, `C:\Users\bob`}, + {"backslash-quote pair untouched", `quote\"inside`, `quote\"inside`}, + // Documented sharp edge of the contract: a path or string that + // embeds a literal backslash-n IS rewritten because the helper + // cannot distinguish "model emitted \n thinking it would become a + // newline" from "user pasted a path that happens to start with + // \new". Callers who need the literal sequence must double the + // backslash (`\\new`) or pipe the body via --content-stdin / + // --description-stdin. This test pins that intentional behavior. + {"path starting with backslash-n is mutated", `C:\new\folder`, "C:\new\\folder"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := unescapeFlagText(tt.in) + if got != tt.want { + t.Errorf("unescapeFlagText(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + func TestTruncateID(t *testing.T) { tests := []struct { name string diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index b65050ec51..9c56d80ce5 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -771,6 +771,39 @@ func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) { } } +// TestInjectRuntimeConfigDirectsMultiLineWritesToStdin pins the guidance that +// any multi-line content for `multica issue comment add` must go through +// `--content-stdin` + a HEREDOC. Agents that reached for the inline +// `--content "...\n\n..."` form ended up with literal 4-char `\n` sequences +// in stored comments because bash does not expand backslash escapes inside +// double quotes; see MUL-1467. This test prevents the multi-line guidance +// from silently regressing back into a "for special characters" footnote. +func TestInjectRuntimeConfigDirectsMultiLineWritesToStdin(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); 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) + + for _, want := range []string{ + "multi-line content", + "MUST pipe via stdin", + "--content-stdin", + "<<'COMMENT'", + "`--description`", + "--description-stdin", + } { + if !strings.Contains(s, want) { + t.Errorf("CLAUDE.md missing multi-line guidance %q\n---\n%s", want, s) + } + } +} + func TestInjectRuntimeConfigAutopilotRunOnlyNoIssueWorkflow(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index f49295874f..62ecc259c2 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -86,7 +86,17 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--assignee X] [--parent ] [--status X]` — Create a new issue\n") b.WriteString("- `multica issue assign --to ` — Assign an issue to a member or agent by name (use --unassign to remove assignee)\n") b.WriteString("- `multica issue comment add --content \"...\" [--parent ]` — Post a comment (use --parent to reply to a specific comment)\n") - b.WriteString(" - For content with special characters (backticks, quotes), pipe via stdin: `cat <<'COMMENT' | multica issue comment add --content-stdin`\n") + b.WriteString(" - **For multi-line content (anything with line breaks, paragraphs, code blocks, backticks, or quotes), you MUST pipe via stdin** — bash does NOT expand `\\n` inside double quotes, so writing `--content \"para1\\n\\npara2\"` stores the literal 4-char sequence and the comment renders without line breaks. Use a HEREDOC instead:\n") + b.WriteString("\n") + b.WriteString(" ```\n") + b.WriteString(" cat <<'COMMENT' | multica issue comment add --content-stdin\n") + b.WriteString(" First paragraph.\n") + b.WriteString("\n") + b.WriteString(" Second paragraph with `code` and \"quotes\".\n") + b.WriteString(" COMMENT\n") + b.WriteString(" ```\n") + b.WriteString("\n") + b.WriteString(" - The same rule applies to `--description` on `multica issue create` and `multica issue update` — use `--description-stdin` and pipe a HEREDOC for any multi-line description; the inline `--description \"...\"` form is for short single-line text only.\n") b.WriteString("- `multica issue comment delete ` — Delete a comment\n") b.WriteString("- `multica issue status ` — Update issue status (todo, in_progress, in_review, done, blocked)\n") b.WriteString("- `multica issue update [--title X] [--description X] [--priority X]` — Update issue fields\n") From dabebe0c12ef72ce573a626abfe42aed9c8031eb Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:34:07 +0800 Subject: [PATCH 13/38] docs(changelog): publish v0.2.18 release notes (#1745) * docs(changelog): publish v0.2.18 release notes Today's release covers 13 PRs since v0.2.17. Spotlight is the full Issue Labels feature (backend + CLI + Web UI), plus the Labs settings tab, sidebar invitation indicator, and the sharded Redis realtime relay. Improvements and fixes round out comment rendering, project-icon usage across the app, self-host env-var pass-through, and several Windows-specific agent issues. * docs(changelog): simplify v0.2.18 entries Trim each line to a short, user-facing sentence; drop implementation detail (sharded relay, build-id symlinks, --description-stdin, etc.) per review feedback that the previous draft was too detailed. --- apps/web/features/landing/i18n/en.ts | 21 +++++++++++++++++++++ apps/web/features/landing/i18n/zh.ts | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index a1a62535be..1793b1f082 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -283,6 +283,27 @@ export function createEnDict(allowSignup: boolean): LandingDict { fixes: "Bug Fixes", }, entries: [ + { + version: "0.2.18", + date: "2026-04-27", + title: "Issue Labels, Labs Tab & Sidebar Invite Dot", + changes: [], + features: [ + "Issue labels — color-code and filter issues across list, board and detail views", + "Labs settings tab for experimental toggles", + "Sidebar shows a dot when you have an unread workspace invite", + ], + improvements: [ + "Project picker now shows the selected project's icon", + "Sidebar parent items stay highlighted on detail pages", + "Self-hosted deployments correctly honor signup gating env vars", + ], + fixes: [ + "Agent comments preserve line breaks again", + "Desktop RPM no longer conflicts with Slack / VS Code on Fedora", + "Windows agents handle multi-line prompts correctly", + ], + }, { version: "0.2.17", date: "2026-04-26", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 2e403ec9b4..a81fa76878 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -283,6 +283,27 @@ export function createZhDict(allowSignup: boolean): LandingDict { fixes: "问题修复", }, entries: [ + { + version: "0.2.18", + date: "2026-04-27", + title: "Issue 标签、Labs 设置页与邀请红点", + changes: [], + features: [ + "Issue 标签——给 Issue 上色、分类,列表、看板和详情页都能用", + "新增 Labs 设置页,集中放实验性开关", + "有未读工作区邀请时,侧边栏会出现红点提示", + ], + improvements: [ + "Project 选择器会显示当前所选 Project 的图标", + "进入详情页时,侧边栏父级菜单保持高亮", + "自托管部署正确读取注册放行相关的环境变量", + ], + fixes: [ + "Agent 评论的换行恢复正常显示", + "桌面端 RPM 不再与 Slack / VS Code 在 Fedora 上冲突", + "Windows 下 Agent 能正确处理多行 prompt", + ], + }, { version: "0.2.17", date: "2026-04-26", From d63e7c1c45bf1e643b8f5367dd49b8be086233e0 Mon Sep 17 00:00:00 2001 From: Alex Fishlock Date: Mon, 27 Apr 2026 11:47:11 +0200 Subject: [PATCH 14/38] ci(release): skip homebrew-tap publish on forks (#1687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release job uses GoReleaser to bump the formula in multica-ai/homebrew-tap. Forks don't have HOMEBREW_TAP_GITHUB_TOKEN and should not publish to that tap, so the job currently fails on every fork tag push (401 Bad credentials against the upstream tap). This makes the workflow red on downstream forks even though the actual artifact pipeline (verify → docker-backend-build → docker-backend-merge) succeeds and produces a usable image. Gate the release job on `github.repository_owner == 'multica-ai'`. Upstream behaviour unchanged. Forks now see a clean green run for docker artifacts only. --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8eaa5959b5..b75c47bfbc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,12 @@ jobs: release: needs: verify + # Only run on the canonical upstream repo. Forks don't have the + # HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to + # `multica-ai/homebrew-tap` anyway. Without this guard, every fork's + # tag push fails this job (401 against the upstream tap), which makes + # downstream CI go red without affecting the actual artifact pipeline. + if: github.repository_owner == 'multica-ai' runs-on: ubuntu-latest steps: - name: Checkout From 4c81fbed2b5d5262d86cfb0df55cd7b32d438101 Mon Sep 17 00:00:00 2001 From: songlei Date: Mon, 27 Apr 2026 17:47:30 +0800 Subject: [PATCH 15/38] fix(daemon/windows): break out of parent shell Job Object so daemon survives Approved and merged via Multica after CI passed. --- server/cmd/multica/cmd_daemon.go | 46 ++++++++++++++++---- server/cmd/multica/cmd_daemon_unix.go | 13 +++++- server/cmd/multica/cmd_daemon_windows.go | 53 +++++++++++++++++++----- 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index c8833bf0b4..fc5fbd82ae 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -174,11 +174,29 @@ func runDaemonBackground(cmd *cobra.Command) error { child := exec.Command(exePath, args...) child.Stdout = logFile child.Stderr = logFile - child.SysProcAttr = daemonSysProcAttr() + // On Windows we want to break the child out of the parent shell's Job + // Object so the daemon survives parent-shell exit. If the parent's Job + // has not granted BREAKAWAY_OK, CreateProcess returns + // ERROR_ACCESS_DENIED — fall back to spawning without breakaway, which + // matches the pre-fix behaviour. On Unix the bool is a no-op. + child.SysProcAttr = daemonSysProcAttr(true) if err := child.Start(); err != nil { - logFile.Close() - return fmt.Errorf("start daemon: %w", err) + if isAccessDeniedSpawnErr(err) { + // Retry without breakaway. Reset the cmd state — exec.Cmd is + // not safe to Start() twice, so build a fresh one. + child = exec.Command(exePath, args...) + child.Stdout = logFile + child.Stderr = logFile + child.SysProcAttr = daemonSysProcAttr(false) + if err := child.Start(); err != nil { + logFile.Close() + return fmt.Errorf("start daemon (no breakaway): %w", err) + } + } else { + logFile.Close() + return fmt.Errorf("start daemon: %w", err) + } } logFile.Close() pid := child.Process.Pid @@ -327,12 +345,26 @@ func runDaemonForeground(cmd *cobra.Command) error { } child.Stdout = logFile child.Stderr = logFile - child.SysProcAttr = daemonSysProcAttr() + // Break out of the parent's Job Object on Windows; see the + // runDaemonBackground call site for rationale. + child.SysProcAttr = daemonSysProcAttr(true) if err := child.Start(); err != nil { - logFile.Close() - logger.Error("failed to start new daemon", "error", err) - return nil + if isAccessDeniedSpawnErr(err) { + child = exec.Command(restartBin, args...) + child.Stdout = logFile + child.Stderr = logFile + child.SysProcAttr = daemonSysProcAttr(false) + if err := child.Start(); err != nil { + logFile.Close() + logger.Error("failed to start new daemon (no breakaway)", "error", err) + return nil + } + } else { + logFile.Close() + logger.Error("failed to start new daemon", "error", err) + return nil + } } logFile.Close() child.Process.Release() diff --git a/server/cmd/multica/cmd_daemon_unix.go b/server/cmd/multica/cmd_daemon_unix.go index 9a84648b79..5c2a99c231 100644 --- a/server/cmd/multica/cmd_daemon_unix.go +++ b/server/cmd/multica/cmd_daemon_unix.go @@ -11,10 +11,21 @@ import ( "syscall" ) -func daemonSysProcAttr() *syscall.SysProcAttr { +// daemonSysProcAttr returns the attributes used when spawning the background +// daemon. The withBreakaway argument exists only to share a signature with +// the Windows version (where it controls CREATE_BREAKAWAY_FROM_JOB); on +// Unix Setsid alone is sufficient to detach the child from its parent's +// session and process group. +func daemonSysProcAttr(_ bool) *syscall.SysProcAttr { return &syscall.SysProcAttr{Setsid: true} } +// isAccessDeniedSpawnErr is always false on Unix. The Windows version +// looks for ERROR_ACCESS_DENIED to detect "parent Job Object disallowed +// breakaway" and trigger the breakaway-disabled retry; that retry is a +// no-op on Unix. +func isAccessDeniedSpawnErr(_ error) bool { return false } + func notifyShutdownContext(parent context.Context) (context.Context, context.CancelFunc) { return signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) } diff --git a/server/cmd/multica/cmd_daemon_windows.go b/server/cmd/multica/cmd_daemon_windows.go index 6836bfb6eb..c90bbc865c 100644 --- a/server/cmd/multica/cmd_daemon_windows.go +++ b/server/cmd/multica/cmd_daemon_windows.go @@ -4,6 +4,7 @@ package main import ( "context" + "errors" "io" "os" "os/signal" @@ -12,25 +13,57 @@ import ( ) const ( + // detachedProcess severs the inherited console so closing the parent + // cmd/PowerShell window no longer propagates CTRL_CLOSE_EVENT to the daemon. detachedProcess = 0x00000008 - sigBreak = syscall.Signal(0x15) + // createBreakawayFromJob lets the daemon escape its parent shell's Job + // Object. Modern Windows Terminal / cmd.exe / PowerShell host the + // processes they spawn inside a Job Object that has KILL_ON_JOB_CLOSE + // set, so when the parent shell exits the kernel kills every process + // inside that job — including a child we tried to "detach" with + // detachedProcess alone. detachedProcess only severs the console, not + // the Job Object inheritance. Adding createBreakawayFromJob makes + // CreateProcess place the new process outside the parent's Job, so + // the daemon survives parent-shell exit. + // + // If the parent's Job has not granted BREAKAWAY_OK, CreateProcess + // returns ERROR_ACCESS_DENIED. In that case the caller falls back to + // detachedProcess alone — the daemon is then at the mercy of the + // parent's Job lifecycle, which is the pre-fix behaviour. + createBreakawayFromJob = 0x01000000 + 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 { +// daemon. The default is detachedProcess + createBreakawayFromJob so the +// daemon survives both the parent's console close and the parent's Job +// Object close. The daemon's stdout/stderr are already redirected to the +// log file before Start() is called, so losing the console is safe; and +// `daemon stop` talks to it via HTTP /shutdown rather than +// GenerateConsoleCtrlEvent, so losing the process group is also safe. +// +// The withBreakaway argument exists so the caller can retry with +// withBreakaway=false when CreateProcess fails with ERROR_ACCESS_DENIED +// (the parent Job does not allow breakaway). +func daemonSysProcAttr(withBreakaway bool) *syscall.SysProcAttr { + flags := uint32(detachedProcess) + if withBreakaway { + flags |= createBreakawayFromJob + } return &syscall.SysProcAttr{ HideWindow: true, - CreationFlags: detachedProcess, + CreationFlags: flags, } } +// isAccessDeniedSpawnErr reports whether the error returned from +// (*exec.Cmd).Start() is the Windows ERROR_ACCESS_DENIED, which is what +// CreateProcess returns when CREATE_BREAKAWAY_FROM_JOB is requested but +// the parent's Job Object has not set JOB_OBJECT_LIMIT_BREAKAWAY_OK. +func isAccessDeniedSpawnErr(err error) bool { + return errors.Is(err, syscall.ERROR_ACCESS_DENIED) +} + func notifyShutdownContext(parent context.Context) (context.Context, context.CancelFunc) { return signal.NotifyContext(parent, os.Interrupt, sigBreak) } From 6bd5bbad9c9c22c73aa75e158930ca91551992f7 Mon Sep 17 00:00:00 2001 From: dyjxg4xygary <1140408986@qq.com> Date: Mon, 27 Apr 2026 06:23:31 -0400 Subject: [PATCH 16/38] fix: timeout stalled Codex turns (#1730) * fix: timeout stalled codex turns * fix: count codex progress events as activity --- CLI_AND_DAEMON.md | 1 + server/cmd/multica/cmd_daemon.go | 8 ++ server/internal/daemon/config.go | 132 ++++++++++-------- server/internal/daemon/daemon.go | 39 ++++-- server/internal/daemon/daemon_test.go | 93 +++++++++++++ server/internal/daemon/types.go | 17 +-- server/pkg/agent/agent.go | 17 +-- server/pkg/agent/codex.go | 188 +++++++++++++++++++++----- server/pkg/agent/codex_test.go | 169 +++++++++++++++++++++++ 9 files changed, 542 insertions(+), 122 deletions(-) diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index 2144d3cbfc..c8c43fb726 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -166,6 +166,7 @@ Daemon behavior is configured via flags or environment variables: | Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | | Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | | Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` | +| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` | | Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | | Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname | | Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname | diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index fc5fbd82ae..604bb200af 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -65,6 +65,7 @@ func init() { f.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)") f.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)") f.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)") + f.Duration("codex-semantic-inactivity-timeout", 0, "Codex semantic inactivity timeout (env: MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT)") f.Int("max-concurrent-tasks", 0, "Max tasks running in parallel (env: MULTICA_DAEMON_MAX_CONCURRENT_TASKS)") daemonLogsCmd.Flags().BoolP("follow", "f", false, "Follow log output") @@ -81,6 +82,7 @@ func init() { rf.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)") rf.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)") rf.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)") + rf.Duration("codex-semantic-inactivity-timeout", 0, "Codex semantic inactivity timeout (env: MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT)") rf.Int("max-concurrent-tasks", 0, "Max tasks running in parallel (env: MULTICA_DAEMON_MAX_CONCURRENT_TASKS)") daemonCmd.AddCommand(daemonStartCmd) @@ -259,6 +261,9 @@ func buildDaemonStartArgs(cmd *cobra.Command) []string { if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 { args = append(args, "--agent-timeout", d.String()) } + if d, _ := cmd.Flags().GetDuration("codex-semantic-inactivity-timeout"); d > 0 { + args = append(args, "--codex-semantic-inactivity-timeout", d.String()) + } if n, _ := cmd.Flags().GetInt("max-concurrent-tasks"); n > 0 { args = append(args, "--max-concurrent-tasks", strconv.Itoa(n)) } @@ -300,6 +305,9 @@ func runDaemonForeground(cmd *cobra.Command) error { if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 { overrides.AgentTimeout = d } + if d, _ := cmd.Flags().GetDuration("codex-semantic-inactivity-timeout"); d > 0 { + overrides.CodexSemanticInactivityTimeout = d + } if n, _ := cmd.Flags().GetInt("max-concurrent-tasks"); n > 0 { overrides.MaxConcurrentTasks = n } diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index fbce1698b1..e52ebf386c 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -11,57 +11,60 @@ import ( ) const ( - DefaultServerURL = "ws://localhost:8080/ws" - DefaultPollInterval = 3 * time.Second - DefaultHeartbeatInterval = 15 * time.Second - DefaultAgentTimeout = 2 * time.Hour - DefaultRuntimeName = "Local Agent" - DefaultWorkspaceSyncInterval = 30 * time.Second - DefaultHealthPort = 19514 - DefaultMaxConcurrentTasks = 20 - DefaultGCInterval = 1 * time.Hour - DefaultGCTTL = 24 * time.Hour // 1 day — AI-coding issues rarely stay open long - DefaultGCOrphanTTL = 72 * time.Hour // 3 days — orphans with no meta (crashes, pre-GC leftovers) + DefaultServerURL = "ws://localhost:8080/ws" + DefaultPollInterval = 3 * time.Second + DefaultHeartbeatInterval = 15 * time.Second + DefaultAgentTimeout = 2 * time.Hour + DefaultCodexSemanticInactivityTimeout = 10 * time.Minute + DefaultRuntimeName = "Local Agent" + DefaultWorkspaceSyncInterval = 30 * time.Second + DefaultHealthPort = 19514 + DefaultMaxConcurrentTasks = 20 + DefaultGCInterval = 1 * time.Hour + DefaultGCTTL = 24 * time.Hour // 1 day — AI-coding issues rarely stay open long + DefaultGCOrphanTTL = 72 * time.Hour // 3 days — orphans with no meta (crashes, pre-GC leftovers) ) // Config holds all daemon configuration. type Config struct { - ServerBaseURL string - DaemonID string - LegacyDaemonIDs []string // historical daemon_ids this machine may have registered under; reported at register time so the server can merge old runtime rows - DeviceName string - RuntimeName string - 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 - 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) - MaxConcurrentTasks int // max tasks running in parallel (default: 20) - GCEnabled bool // enable periodic workspace garbage collection (default: true) - GCInterval time.Duration // how often the GC loop runs (default: 1h) - GCTTL time.Duration // clean dirs whose issue is done/canceled and updated_at < now()-TTL (default: 24h) - GCOrphanTTL time.Duration // clean orphan dirs with no meta older than this (default: 72h). Dirs whose issue returned 404 are cleaned immediately. - PollInterval time.Duration - HeartbeatInterval time.Duration - AgentTimeout time.Duration + ServerBaseURL string + DaemonID string + LegacyDaemonIDs []string // historical daemon_ids this machine may have registered under; reported at register time so the server can merge old runtime rows + DeviceName string + RuntimeName string + 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 + 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) + MaxConcurrentTasks int // max tasks running in parallel (default: 20) + GCEnabled bool // enable periodic workspace garbage collection (default: true) + GCInterval time.Duration // how often the GC loop runs (default: 1h) + GCTTL time.Duration // clean dirs whose issue is done/canceled and updated_at < now()-TTL (default: 24h) + GCOrphanTTL time.Duration // clean orphan dirs with no meta older than this (default: 72h). Dirs whose issue returned 404 are cleaned immediately. + PollInterval time.Duration + HeartbeatInterval time.Duration + AgentTimeout time.Duration + CodexSemanticInactivityTimeout time.Duration } // Overrides allows CLI flags to override environment variables and defaults. // Zero values are ignored and the env/default value is used instead. type Overrides struct { - ServerURL string - WorkspacesRoot string - PollInterval time.Duration - HeartbeatInterval time.Duration - AgentTimeout time.Duration - MaxConcurrentTasks int - DaemonID string - DeviceName string - RuntimeName string - Profile string // profile name (empty = default) - HealthPort int // health check port (0 = use default) + ServerURL string + WorkspacesRoot string + PollInterval time.Duration + HeartbeatInterval time.Duration + AgentTimeout time.Duration + CodexSemanticInactivityTimeout time.Duration + MaxConcurrentTasks int + DaemonID string + DeviceName string + RuntimeName string + Profile string // profile name (empty = default) + HealthPort int // health check port (0 = use default) } // LoadConfig builds the daemon configuration from environment variables @@ -184,6 +187,14 @@ func LoadConfig(overrides Overrides) (Config, error) { agentTimeout = overrides.AgentTimeout } + codexSemanticInactivityTimeout, err := durationFromEnv("MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT", DefaultCodexSemanticInactivityTimeout) + if err != nil { + return Config{}, err + } + if overrides.CodexSemanticInactivityTimeout > 0 { + codexSemanticInactivityTimeout = overrides.CodexSemanticInactivityTimeout + } + maxConcurrentTasks, err := intFromEnv("MULTICA_DAEMON_MAX_CONCURRENT_TASKS", DefaultMaxConcurrentTasks) if err != nil { return Config{}, err @@ -289,24 +300,25 @@ func LoadConfig(overrides Overrides) (Config, error) { } return Config{ - ServerBaseURL: serverBaseURL, - DaemonID: daemonID, - LegacyDaemonIDs: legacyDaemonIDs, - DeviceName: deviceName, - RuntimeName: runtimeName, - Profile: profile, - Agents: agents, - WorkspacesRoot: workspacesRoot, - KeepEnvAfterTask: keepEnv, - GCEnabled: gcEnabled, - GCInterval: gcInterval, - GCTTL: gcTTL, - GCOrphanTTL: gcOrphanTTL, - HealthPort: healthPort, - MaxConcurrentTasks: maxConcurrentTasks, - PollInterval: pollInterval, - HeartbeatInterval: heartbeatInterval, - AgentTimeout: agentTimeout, + ServerBaseURL: serverBaseURL, + DaemonID: daemonID, + LegacyDaemonIDs: legacyDaemonIDs, + DeviceName: deviceName, + RuntimeName: runtimeName, + Profile: profile, + Agents: agents, + WorkspacesRoot: workspacesRoot, + KeepEnvAfterTask: keepEnv, + GCEnabled: gcEnabled, + GCInterval: gcInterval, + GCTTL: gcTTL, + GCOrphanTTL: gcOrphanTTL, + HealthPort: healthPort, + MaxConcurrentTasks: maxConcurrentTasks, + PollInterval: pollInterval, + HeartbeatInterval: heartbeatInterval, + AgentTimeout: agentTimeout, + CodexSemanticInactivityTimeout: codexSemanticInactivityTimeout, }, nil } diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index c076e81d91..0d5460a4c1 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -984,7 +984,11 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) { // 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, "agent_error"); err != nil { + failureReason := result.FailureReason + if failureReason == "" { + failureReason = "agent_error" + } + if err := d.client.FailTask(ctx, task.ID, result.Comment, result.SessionID, result.WorkDir, failureReason); err != nil { taskLog.Error("report blocked task failed", "error", err) } default: @@ -1174,12 +1178,13 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo model = entry.Model } execOpts := agent.ExecOptions{ - Cwd: env.WorkDir, - Model: model, - Timeout: d.cfg.AgentTimeout, - ResumeSessionID: task.PriorSessionID, - CustomArgs: customArgs, - McpConfig: mcpConfig, + Cwd: env.WorkDir, + Model: model, + Timeout: d.cfg.AgentTimeout, + SemanticInactivityTimeout: d.cfg.CodexSemanticInactivityTimeout, + 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 @@ -1264,13 +1269,18 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo // 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. + comment := result.Error + if comment == "" { + comment = fmt.Sprintf("%s timed out after %s", provider, d.cfg.AgentTimeout) + } 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, + Status: "blocked", + Comment: comment, + SessionID: result.SessionID, + WorkDir: env.WorkDir, + EnvRoot: env.RootDir, + FailureReason: "timeout", + Usage: usageEntries, }, nil case "cancelled": // Server cancelled the task (e.g. issue reassignment, user cancel). @@ -1363,6 +1373,8 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro sendCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if err := d.client.ReportTaskMessages(sendCtx, taskID, toSend); err != nil { taskLog.Debug("failed to report task messages", "error", err) + } else { + taskLog.Debug("reported task messages", "count", len(toSend), "last_seq", toSend[len(toSend)-1].Seq) } cancel() } @@ -1436,6 +1448,7 @@ func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, pro toolName = callIDToTool[msg.CallID] mu.Unlock() } + taskLog.Info("tool_result observed", "seq", s, "tool", toolName, "call_id", msg.CallID) mu.Lock() batch = append(batch, TaskMessageData{ Seq: int(s), diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index 9e8ce1093f..2495fdd853 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -10,10 +10,12 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "sync/atomic" "testing" + "time" "github.com/multica-ai/multica/server/internal/daemon/repocache" "github.com/multica-ai/multica/server/pkg/agent" @@ -421,6 +423,97 @@ func TestExecuteAndDrain_NoRetryWhenSessionEstablished(t *testing.T) { } } +func TestExecuteAndDrain_CodexInactivityReportsToolResultTranscript(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is POSIX-only") + } + + fakePath := filepath.Join(t.TempDir(), "codex") + script := "#!/bin/sh\n" + + `read line` + "\n" + + `echo '{"jsonrpc":"2.0","id":1,"result":{}}'` + "\n" + + `read line` + "\n" + + `read line` + "\n" + + `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-drain"}}}'` + "\n" + + `read line` + "\n" + + `echo '{"jsonrpc":"2.0","id":3,"result":{}}'` + "\n" + + `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-drain","turn":{"id":"turn-drain"}}}'` + "\n" + + `echo '{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thr-drain","item":{"type":"commandExecution","id":"cmd-1","command":"git status"}}}'` + "\n" + + `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-drain","item":{"type":"commandExecution","id":"cmd-1","aggregatedOutput":"clean"}}}'` + "\n" + + `sleep 5` + "\n" + if err := os.WriteFile(fakePath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake codex: %v", err) + } + if err := os.Chmod(fakePath, 0o755); err != nil { + t.Fatalf("chmod fake codex: %v", err) + } + + var mu sync.Mutex + var reported []TaskMessageData + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/daemon/tasks/task-stale/messages" { + http.NotFound(w, r) + return + } + var body struct { + Messages []TaskMessageData `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode task messages: %v", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + mu.Lock() + reported = append(reported, body.Messages...) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + backend, err := agent.New("codex", agent.Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if err != nil { + t.Fatalf("new codex backend: %v", err) + } + d := &Daemon{client: NewClient(srv.URL), logger: slog.Default()} + result, tools, err := d.executeAndDrain(context.Background(), backend, "prompt", agent.ExecOptions{ + Timeout: 5 * time.Second, + SemanticInactivityTimeout: 100 * time.Millisecond, + }, slog.Default(), "task-stale") + if err != nil { + t.Fatalf("executeAndDrain: %v", err) + } + if result.Status != "timeout" { + t.Fatalf("expected timeout, got status=%q error=%q", result.Status, result.Error) + } + if tools != 1 { + t.Fatalf("expected one tool use, got %d", tools) + } + + deadline := time.Now().Add(2 * time.Second) + for { + mu.Lock() + var gotToolUse, gotToolResult bool + for _, msg := range reported { + if msg.Seq == 1 && msg.Type == "tool_use" && msg.Tool == "exec_command" { + gotToolUse = true + } + if msg.Seq == 2 && msg.Type == "tool_result" && msg.Tool == "exec_command" && msg.Output == "clean" { + gotToolResult = true + } + } + mu.Unlock() + if gotToolUse && gotToolResult { + return + } + if time.Now().After(deadline) { + mu.Lock() + defer mu.Unlock() + t.Fatalf("expected tool_use seq=1 and tool_result seq=2 in transcript, got %+v", reported) + } + time.Sleep(10 * time.Millisecond) + } +} + // blockingBackend returns a Session whose Result channel is never written to, // so executeAndDrain can only exit via the drainCtx.Done() path. type blockingBackend struct{} diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index dba4a9f0ec..2c64e9cae4 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -85,12 +85,13 @@ type TaskUsageEntry struct { // TaskResult is the outcome of executing a task. type TaskResult struct { - Status string `json:"status"` - Comment string `json:"comment"` - BranchName string `json:"branch_name,omitempty"` - EnvType string `json:"env_type,omitempty"` - SessionID string `json:"session_id,omitempty"` // Claude session ID for future resumption - WorkDir string `json:"work_dir,omitempty"` // working directory used during execution - EnvRoot string `json:"-"` // env root dir for writing GC metadata (not sent to server) - Usage []TaskUsageEntry `json:"usage,omitempty"` // per-model token usage + Status string `json:"status"` + Comment string `json:"comment"` + BranchName string `json:"branch_name,omitempty"` + EnvType string `json:"env_type,omitempty"` + SessionID string `json:"session_id,omitempty"` // Claude session ID for future resumption + WorkDir string `json:"work_dir,omitempty"` // working directory used during execution + EnvRoot string `json:"-"` // env root dir for writing GC metadata (not sent to server) + FailureReason string `json:"-"` // internal server failure classification + Usage []TaskUsageEntry `json:"usage,omitempty"` // per-model token usage } diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index 842e616f22..4c4256c120 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -22,14 +22,15 @@ type Backend interface { // ExecOptions configures a single execution. type ExecOptions struct { - Cwd string - Model string - SystemPrompt string - MaxTurns int - Timeout time.Duration - ResumeSessionID string // if non-empty, resume a previous agent session - CustomArgs []string // additional CLI arguments appended to the agent command - McpConfig json.RawMessage // if non-nil, MCP server config to pass via --mcp-config + Cwd string + Model string + SystemPrompt string + MaxTurns int + Timeout time.Duration + SemanticInactivityTimeout time.Duration + ResumeSessionID string // if non-empty, resume a previous agent session + CustomArgs []string // additional CLI arguments appended to the agent command + McpConfig json.RawMessage // if non-nil, MCP server config to pass via --mcp-config } // Session represents a running agent execution. diff --git a/server/pkg/agent/codex.go b/server/pkg/agent/codex.go index d430bd89ea..55165b101e 100644 --- a/server/pkg/agent/codex.go +++ b/server/pkg/agent/codex.go @@ -25,7 +25,10 @@ var codexBlockedArgs = map[string]blockedArgMode{ // user supplied a custom_args flag that the `app-server` subcommand // rejects). Kept as its own constant so bumping codex independently of // other agents stays easy if codex starts shipping longer failure traces. -const codexStderrTailBytes = 2048 +const ( + codexStderrTailBytes = 2048 + defaultCodexSemanticInactivityTimeout = 10 * time.Minute +) // codexBackend implements Backend by spawning `codex app-server --listen stdio://` // and communicating via JSON-RPC 2.0 over stdin/stdout. @@ -46,6 +49,10 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti if timeout == 0 { timeout = 20 * time.Minute } + semanticInactivityTimeout := opts.SemanticInactivityTimeout + if semanticInactivityTimeout == 0 { + semanticInactivityTimeout = defaultCodexSemanticInactivityTimeout + } runCtx, cancel := context.WithTimeout(ctx, timeout) codexArgs := append([]string{"app-server", "--listen", "stdio://"}, filterCustomArgs(opts.CustomArgs, codexBlockedArgs, b.cfg.Logger)...) @@ -79,6 +86,7 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti msgCh := make(chan Message, 256) resCh := make(chan Result, 1) + semanticActivityCh := make(chan string, 256) var outputMu sync.Mutex var output strings.Builder @@ -93,12 +101,18 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti pending: make(map[int]*pendingRPC), notificationProtocol: "unknown", onMessage: func(msg Message) { + logCodexAgentMessage(b.cfg.Logger, msg) if msg.Type == MessageText { outputMu.Lock() output.WriteString(msg.Content) outputMu.Unlock() } trySend(msgCh, msg) + trySendString(semanticActivityCh, describeCodexSemanticActivity(msg)) + }, + onSemanticActivity: func(description string) { + b.cfg.Logger.Debug("codex semantic activity observed", "activity", description) + trySendString(semanticActivityCh, description) }, onTurnDone: func(aborted bool) { select { @@ -207,26 +221,51 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti return } - // Wait for turn completion or context cancellation - select { - case aborted := <-turnDone: - switch { - case aborted: - finalStatus = "aborted" - finalError = "turn was aborted" - default: - if errMsg := c.getTurnError(); errMsg != "" { - finalStatus = "failed" - finalError = errMsg + lastSemanticActivity := time.Now() + lastSemanticActivityDescription := "turn/start" + semanticTimer := time.NewTimer(semanticInactivityTimeout) + defer semanticTimer.Stop() + + waitingForTurn := true + for waitingForTurn { + select { + case aborted := <-turnDone: + waitingForTurn = false + switch { + case aborted: + finalStatus = "aborted" + finalError = "turn was aborted" + default: + if errMsg := c.getTurnError(); errMsg != "" { + finalStatus = "failed" + finalError = errMsg + } } - } - case <-runCtx.Done(): - if runCtx.Err() == context.DeadlineExceeded { + case activity := <-semanticActivityCh: + lastSemanticActivity = time.Now() + lastSemanticActivityDescription = activity + resetTimer(semanticTimer, semanticInactivityTimeout) + case <-semanticTimer.C: + waitingForTurn = false finalStatus = "timeout" - finalError = fmt.Sprintf("codex timed out after %s", timeout) - } else { - finalStatus = "aborted" - finalError = "execution cancelled" + finalError = fmt.Sprintf("codex semantic inactivity timeout after %s without agent progress (last activity: %s)", semanticInactivityTimeout, lastSemanticActivityDescription) + b.cfg.Logger.Warn("codex semantic inactivity timeout", + "pid", cmd.Process.Pid, + "thread_id", threadID, + "turn_id", c.turnID, + "timeout", semanticInactivityTimeout.String(), + "last_activity", lastSemanticActivityDescription, + "idle_for", time.Since(lastSemanticActivity).Round(time.Millisecond).String(), + ) + case <-runCtx.Done(): + waitingForTurn = false + if runCtx.Err() == context.DeadlineExceeded { + finalStatus = "timeout" + finalError = fmt.Sprintf("codex timed out after %s", timeout) + } else { + finalStatus = "aborted" + finalError = "execution cancelled" + } } } @@ -337,18 +376,68 @@ func (c *codexClient) startOrResumeThread(ctx context.Context, opts ExecOptions, return threadID, false, nil } +func resetTimer(timer *time.Timer, d time.Duration) { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(d) +} + +func trySendString(ch chan<- string, value string) { + select { + case ch <- value: + default: + } +} + +func logCodexAgentMessage(logger *slog.Logger, msg Message) { + if logger == nil { + return + } + attrs := []any{ + "type", string(msg.Type), + "tool", msg.Tool, + "call_id", msg.CallID, + "status", msg.Status, + "content_len", len(msg.Content), + "output_len", len(msg.Output), + } + logger.Info("codex agent message received", attrs...) + if msg.Type == MessageToolResult { + logger.Info("codex tool_result observed", "tool", msg.Tool, "call_id", msg.CallID, "output_len", len(msg.Output)) + } +} + +func describeCodexSemanticActivity(msg Message) string { + switch msg.Type { + case MessageToolUse, MessageToolResult: + if msg.Tool != "" { + return fmt.Sprintf("%s:%s", msg.Type, msg.Tool) + } + case MessageStatus: + if msg.Status != "" { + return fmt.Sprintf("%s:%s", msg.Type, msg.Status) + } + } + return string(msg.Type) +} + // ── codexClient: JSON-RPC 2.0 transport ── type codexClient struct { - cfg Config - stdin interface{ Write([]byte) (int, error) } - mu sync.Mutex - nextID int - pending map[int]*pendingRPC - threadID string - turnID string - onMessage func(Message) - onTurnDone func(aborted bool) + cfg Config + stdin interface{ Write([]byte) (int, error) } + mu sync.Mutex + nextID int + pending map[int]*pendingRPC + threadID string + turnID string + onMessage func(Message) + onSemanticActivity func(description string) + onTurnDone func(aborted bool) notificationProtocol string // "unknown", "legacy", "raw" turnStarted bool @@ -416,6 +505,13 @@ func (c *codexClient) request(ctx context.Context, method string, params any) (j c.mu.Unlock() return nil, fmt.Errorf("write %s: %w", method, err) } + if method == "turn/start" { + threadID := "" + if paramMap, ok := params.(map[string]any); ok { + threadID, _ = paramMap["threadId"].(string) + } + c.cfg.Logger.Info("codex turn/start sent", "request_id", id, "thread_id", threadID) + } select { case res := <-pr.ch: @@ -666,6 +762,8 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any case "turn/completed": turnID := extractNestedString(params, "turn", "id") status := extractNestedString(params, "turn", "status") + threadID, _ := params["threadId"].(string) + c.cfg.Logger.Info("codex turn/completed received", "thread_id", threadID, "turn_id", turnID, "status", status) aborted := status == "cancelled" || status == "canceled" || status == "aborted" || status == "interrupted" @@ -730,13 +828,15 @@ func (c *codexClient) handleRawNotification(method string, params map[string]any } func (c *codexClient) handleItemNotification(method string, params map[string]any) { - item, ok := params["item"].(map[string]any) - if !ok { - return - } - + item, _ := params["item"].(map[string]any) itemType, _ := item["type"].(string) itemID, _ := item["id"].(string) + if isCodexItemProgressActivity(method) && c.onSemanticActivity != nil { + c.onSemanticActivity(describeCodexItemProgressActivity(method, itemType, itemID)) + } + if item == nil { + return + } switch { case method == "item/started" && itemType == "commandExecution": @@ -793,6 +893,28 @@ func (c *codexClient) handleItemNotification(method string, params map[string]an } } +func isCodexItemProgressActivity(method string) bool { + switch method { + case "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/mcpToolCall/progress": + return true + default: + return false + } +} + +func describeCodexItemProgressActivity(method, itemType, itemID string) string { + if itemType == "" { + itemType = "unknown" + } + if itemID == "" { + return fmt.Sprintf("%s:%s", method, itemType) + } + return fmt.Sprintf("%s:%s:%s", method, itemType, itemID) +} + // extractUsageFromMap extracts token usage from a map that may contain // "usage", "token_usage", or "tokens" fields. Handles various Codex formats. func (c *codexClient) extractUsageFromMap(data map[string]any) { diff --git a/server/pkg/agent/codex_test.go b/server/pkg/agent/codex_test.go index 686889df90..9ba6a25516 100644 --- a/server/pkg/agent/codex_test.go +++ b/server/pkg/agent/codex_test.go @@ -1011,6 +1011,175 @@ func TestCodexExecuteSurfacesStderrWhenChildExitsEarly(t *testing.T) { } } +func TestCodexExecuteTimesOutWhenTurnStopsAfterToolResult(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is POSIX-only") + } + + fakePath := writeFakeCodexAppServer(t, ""+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+ + `read line`+"\n"+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-stale"}}}'`+"\n"+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-stale","turn":{"id":"turn-stale"}}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thr-stale","item":{"type":"commandExecution","id":"cmd-1","command":"git status"}}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-stale","item":{"type":"commandExecution","id":"cmd-1","aggregatedOutput":"clean"}}}'`+"\n"+ + `sleep 5`+"\n") + + result := executeFakeCodex(t, fakePath, ExecOptions{ + Timeout: 5 * time.Second, + SemanticInactivityTimeout: 100 * time.Millisecond, + }) + if result.Status != "timeout" { + t.Fatalf("expected timeout, got status=%q error=%q", result.Status, result.Error) + } + if !strings.Contains(result.Error, "semantic inactivity") { + t.Fatalf("expected semantic inactivity error, got %q", result.Error) + } + if result.SessionID != "thr-stale" { + t.Fatalf("expected session id to be preserved, got %q", result.SessionID) + } +} + +func TestCodexExecuteSemanticInactivityAllowsContinuousMessages(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is POSIX-only") + } + + fakePath := writeFakeCodexAppServer(t, ""+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+ + `read line`+"\n"+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-progress"}}}'`+"\n"+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-progress","turn":{"id":"turn-progress"}}}'`+"\n"+ + `sleep 0.05`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-progress","item":{"type":"agentMessage","id":"msg-1","text":"still working"}}}'`+"\n"+ + `sleep 0.05`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-progress","item":{"type":"commandExecution","id":"cmd-1","aggregatedOutput":"ok"}}}'`+"\n"+ + `sleep 0.05`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-progress","turn":{"id":"turn-progress","status":"completed"}}}'`+"\n") + + result := executeFakeCodex(t, fakePath, ExecOptions{ + Timeout: 5 * time.Second, + SemanticInactivityTimeout: 90 * time.Millisecond, + }) + if result.Status != "completed" { + t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error) + } + if !strings.Contains(result.Output, "still working") { + t.Fatalf("expected streamed text in output, got %q", result.Output) + } +} + +func TestCodexExecuteSemanticInactivityAllowsContinuousDeltaProgress(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is POSIX-only") + } + + fakePath := writeFakeCodexAppServer(t, ""+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+ + `read line`+"\n"+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-delta"}}}'`+"\n"+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-delta","turn":{"id":"turn-delta"}}}'`+"\n"+ + `sleep 0.05`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"item/commandExecution/outputDelta","params":{"threadId":"thr-delta","item":{"type":"commandExecution","id":"cmd-1"},"delta":"line 1\n"}}'`+"\n"+ + `sleep 0.05`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"thr-delta","item":{"type":"agentMessage","id":"msg-1"},"delta":"thinking"}}'`+"\n"+ + `sleep 0.05`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"item/fileChange/outputDelta","params":{"threadId":"thr-delta","item":{"type":"fileChange","id":"patch-1"},"delta":"patched"}}'`+"\n"+ + `sleep 0.05`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"item/mcpToolCall/progress","params":{"threadId":"thr-delta","item":{"type":"mcpToolCall","id":"mcp-1"},"progress":{"message":"still running"}}}'`+"\n"+ + `sleep 0.05`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-delta","turn":{"id":"turn-delta","status":"completed"}}}'`+"\n") + + result := executeFakeCodex(t, fakePath, ExecOptions{ + Timeout: 5 * time.Second, + SemanticInactivityTimeout: 150 * time.Millisecond, + }) + if result.Status != "completed" { + t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error) + } +} + +func TestCodexExecuteSemanticInactivityDoesNotAffectNormalTurnCompletion(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is POSIX-only") + } + + fakePath := writeFakeCodexAppServer(t, ""+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":1,"result":{}}'`+"\n"+ + `read line`+"\n"+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-normal"}}}'`+"\n"+ + `read line`+"\n"+ + `echo '{"jsonrpc":"2.0","id":3,"result":{}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-normal","turn":{"id":"turn-normal"}}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-normal","item":{"type":"agentMessage","id":"msg-1","text":"Done"}}}'`+"\n"+ + `echo '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-normal","turn":{"id":"turn-normal","status":"completed"}}}'`+"\n") + + result := executeFakeCodex(t, fakePath, ExecOptions{ + Timeout: 5 * time.Second, + SemanticInactivityTimeout: 100 * time.Millisecond, + }) + if result.Status != "completed" { + t.Fatalf("expected completed, got status=%q error=%q", result.Status, result.Error) + } + if result.Output != "Done" { + t.Fatalf("expected output Done, got %q", result.Output) + } +} + +func writeFakeCodexAppServer(t *testing.T, body string) string { + t.Helper() + fakePath := filepath.Join(t.TempDir(), "codex") + script := "#!/bin/sh\n" + body + writeTestExecutable(t, fakePath, []byte(script)) + return fakePath +} + +func executeFakeCodex(t *testing.T, fakePath string, opts ExecOptions) Result { + t.Helper() + backend, err := New("codex", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if err != nil { + t.Fatalf("new codex backend: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + session, err := backend.Execute(ctx, "prompt", opts) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + select { + case result, ok := <-session.Result: + if !ok { + t.Fatal("result channel closed without a value") + } + return result + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for result") + return Result{} + } +} + func TestWithAgentStderrAppendsHint(t *testing.T) { t.Parallel() From b77acdf6429954f4fd07235aca29b4464a9907a4 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:24:07 +0800 Subject: [PATCH 17/38] fix(comments): cancel triggered tasks when comment is deleted (#1747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user deletes a comment that triggered an agent task, the agent would still run with the now-deleted content baked into its prompt (fetched at task claim time) — manifesting as "the agent still sees the deleted comment". The FK ON DELETE SET NULL only nullified trigger_comment_id; the queued task itself was never cancelled. DeleteComment now cancels any queued/dispatched/running task whose trigger is the deleted comment, before the comment row is removed. --- .../comment_trigger_integration_test.go | 35 ++++++++++++ server/internal/handler/comment.go | 8 +++ server/internal/service/task.go | 18 ++++++ server/pkg/db/generated/agent.sql.go | 56 +++++++++++++++++++ server/pkg/db/queries/agent.sql | 11 ++++ 5 files changed, 128 insertions(+) diff --git a/server/cmd/server/comment_trigger_integration_test.go b/server/cmd/server/comment_trigger_integration_test.go index 74bb134311..f5b20a0d42 100644 --- a/server/cmd/server/comment_trigger_integration_test.go +++ b/server/cmd/server/comment_trigger_integration_test.go @@ -512,6 +512,41 @@ func TestCommentTriggerThreadInheritedMention(t *testing.T) { }) } +// TestDeleteCommentCancelsTriggeredTasks verifies that deleting a comment +// also cancels any active tasks that were triggered by it. Without this, +// the daemon would still claim the queued task after the FK SET NULL +// nullified its trigger_comment_id, and the agent would either run with a +// stale prompt (race during claim) or with a generic "you are assigned" +// prompt that has no record of the now-deleted user request — both of +// which manifest as "the agent still sees the deleted comment". +func TestDeleteCommentCancelsTriggeredTasks(t *testing.T) { + agentID := getAgentID(t) + issueID := createIssueAssignedToAgent(t, "Delete-comment cancels task test", agentID) + t.Cleanup(func() { + clearTasks(t, issueID) + resp := authRequest(t, "DELETE", "/api/issues/"+issueID, nil) + resp.Body.Close() + }) + + t.Run("deleting trigger comment cancels its queued task", func(t *testing.T) { + clearTasks(t, issueID) + commentID := postComment(t, issueID, "Please fix this bug", nil) + if n := countPendingTasks(t, issueID); n != 1 { + t.Fatalf("expected 1 pending task before delete, got %d", n) + } + + resp := authRequest(t, "DELETE", "/api/comments/"+commentID, nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("DeleteComment: expected 204, got %d", resp.StatusCode) + } + + if n := countPendingTasks(t, issueID); n != 0 { + t.Errorf("expected 0 pending tasks after deleting trigger comment, got %d", n) + } + }) +} + // TestCommentTriggerCoalescing verifies that rapid-fire comments don't create // duplicate tasks (coalescing dedup). func TestCommentTriggerCoalescing(t *testing.T) { diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index a8b9dda216..312e4b60aa 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -555,6 +555,14 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) { // Collect attachment URLs before CASCADE delete removes them. attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), parseUUID(commentId)) + // Cancel any active tasks triggered by this comment so the agent does not + // run with the now-deleted content already embedded in its prompt. Must + // run before DeleteComment because the FK ON DELETE SET NULL would + // otherwise nullify trigger_comment_id and orphan those tasks in queued. + if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), parseUUID(commentId)); err != nil { + slog.Warn("cancel tasks for deleted trigger comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...) + } + if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil { slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...) writeError(w, http.StatusInternalServerError, "failed to delete comment") diff --git a/server/internal/service/task.go b/server/internal/service/task.go index e956c48ff5..52d1ed7b11 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -161,6 +161,24 @@ func (s *TaskService) CancelTasksForIssue(ctx context.Context, issueID pgtype.UU return nil } +// CancelTasksByTriggerComment cancels active tasks whose trigger is the given +// comment. Called from DeleteComment so an agent does not run with the +// now-deleted content already embedded in its prompt. Must be invoked BEFORE +// the comment row is deleted because the FK ON DELETE SET NULL would +// otherwise nullify trigger_comment_id and we'd lose the ability to find +// the affected tasks. +func (s *TaskService) CancelTasksByTriggerComment(ctx context.Context, commentID pgtype.UUID) error { + cancelled, err := s.Queries.CancelAgentTasksByTriggerComment(ctx, commentID) + if err != nil { + return err + } + for _, t := range cancelled { + s.ReconcileAgentStatus(ctx, t.AgentID) + s.broadcastTaskEvent(ctx, protocol.EventTaskCancelled, t) + } + return nil +} + // CancelTask cancels a single task by ID. It broadcasts a task:cancelled event // so frontends can update immediately. func (s *TaskService) CancelTask(ctx context.Context, taskID pgtype.UUID) (*db.AgentTaskQueue, error) { diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index 167cb1bb15..923950afbc 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -216,6 +216,62 @@ func (q *Queries) CancelAgentTasksByIssueAndAgent(ctx context.Context, arg Cance return items, nil } +const cancelAgentTasksByTriggerComment = `-- name: CancelAgentTasksByTriggerComment :many +UPDATE agent_task_queue +SET status = 'cancelled', completed_at = now() +WHERE trigger_comment_id = $1 AND status IN ('queued', 'dispatched', 'running') +RETURNING id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id, attempt, max_attempts, parent_task_id, failure_reason, last_heartbeat_at +` + +// Cancels active tasks whose trigger is the given comment. Called when a +// comment is deleted so the agent does not run with the now-deleted content +// already embedded in its prompt. Must run BEFORE the comment row is deleted +// because the FK ON DELETE SET NULL would otherwise nullify trigger_comment_id +// and we'd lose the ability to find the affected tasks. +func (q *Queries) CancelAgentTasksByTriggerComment(ctx context.Context, triggerCommentID pgtype.UUID) ([]AgentTaskQueue, error) { + rows, err := q.db.Query(ctx, cancelAgentTasksByTriggerComment, triggerCommentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AgentTaskQueue{} + for rows.Next() { + var i AgentTaskQueue + if err := rows.Scan( + &i.ID, + &i.AgentID, + &i.IssueID, + &i.Status, + &i.Priority, + &i.DispatchedAt, + &i.StartedAt, + &i.CompletedAt, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.Context, + &i.RuntimeID, + &i.SessionID, + &i.WorkDir, + &i.TriggerCommentID, + &i.ChatSessionID, + &i.AutopilotRunID, + &i.Attempt, + &i.MaxAttempts, + &i.ParentTaskID, + &i.FailureReason, + &i.LastHeartbeatAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const claimAgentTask = `-- name: ClaimAgentTask :one UPDATE agent_task_queue SET status = 'dispatched', dispatched_at = now() diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index 1e3cd4ab26..f07e7571de 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -115,6 +115,17 @@ UPDATE agent_task_queue SET status = 'cancelled' WHERE agent_id = $1 AND status IN ('queued', 'dispatched', 'running'); +-- name: CancelAgentTasksByTriggerComment :many +-- Cancels active tasks whose trigger is the given comment. Called when a +-- comment is deleted so the agent does not run with the now-deleted content +-- already embedded in its prompt. Must run BEFORE the comment row is deleted +-- because the FK ON DELETE SET NULL would otherwise nullify trigger_comment_id +-- and we'd lose the ability to find the affected tasks. +UPDATE agent_task_queue +SET status = 'cancelled', completed_at = now() +WHERE trigger_comment_id = $1 AND status IN ('queued', 'dispatched', 'running') +RETURNING *; + -- name: GetAgentTask :one SELECT * FROM agent_task_queue WHERE id = $1; From 1292ecf71bc527e941a30a9d69416bf32fd6b9ad Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:24:40 +0800 Subject: [PATCH 18/38] fix(labels): apply label attach optimistically (#1746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(labels): apply attach optimistically so chips render before round-trip Attach went through onSuccess only, so users waited for the server before seeing the new chip — out of step with detach (already optimistic) and with status/assignee/priority via useUpdateIssue. Mirror the detach pattern: snapshot the byIssue cache, look up the full label from the workspace list cache, patch byIssue + the issue list/detail caches via onIssueLabelsChanged in onMutate, and roll back on error. onSuccess and onSettled keep the existing reconcile behavior. * fix(labels): only patch attach when prev label set is known GPT-Boy's review caught a corruption case: when byIssue cache was unpopulated (user clicked before issueLabelsOptions resolved), the optimistic patch fell back to an empty prev.labels, then mirrored [label] into issue list/detail via onIssueLabelsChanged — wiping any denormalized labels already on the issue. Worse, onError only restored byIssue when ctx.prev existed, so the wipe persisted on failure. Match useDetachLabel's invariant: skip the optimistic patch unless prev is in cache. The chip will wait for the round-trip in the rare race window, but caches stay consistent and rollback always works. --- packages/core/labels/mutations.ts | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/core/labels/mutations.ts b/packages/core/labels/mutations.ts index 354c72046f..23a2654d39 100644 --- a/packages/core/labels/mutations.ts +++ b/packages/core/labels/mutations.ts @@ -101,15 +101,37 @@ export function useAttachLabel(issueId: string) { const wsId = useWorkspaceId(); return useMutation({ mutationFn: (labelId: string) => api.attachLabel(issueId, labelId), + onMutate: async (labelId) => { + await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) }); + const prev = qc.getQueryData(labelKeys.byIssue(wsId, issueId)); + // Only patch when we already know the current label set — otherwise + // appending `[label]` to an empty array would wipe denormalized + // labels in issue list/detail caches and rollback couldn't restore + // them. If byIssue isn't cached yet (user clicked before the picker + // fetched), skip the optimistic patch and rely on onSettled refetch. + if (!prev) return { prev }; + if (prev.labels.some((l) => l.id === labelId)) return { prev }; + const list = qc.getQueryData(labelKeys.list(wsId)); + const label = list?.labels.find((l) => l.id === labelId); + if (!label) return { prev }; + const next: IssueLabelsResponse = { ...prev, labels: [...prev.labels, label] }; + qc.setQueryData(labelKeys.byIssue(wsId, issueId), next); + onIssueLabelsChanged(qc, wsId, issueId, next.labels); + return { prev }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prev) { + qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev); + onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels); + } + }, onSuccess: (data: IssueLabelsResponse) => { // Backend may return an empty object when the post-mutation read fails - // (it logs a warning and skips the broadcast). We only apply the list - // when the backend gave us one — otherwise rely on onSettled's - // invalidation to refetch. + // (it logs a warning and skips the broadcast). Only apply the list + // when the backend gave us one — otherwise the optimistic patch from + // onMutate stands until onSettled's invalidation refetches. if (data && Array.isArray(data.labels)) { qc.setQueryData(labelKeys.byIssue(wsId, issueId), data); - // Mirror into the issues list / detail caches so list/board chips - // update immediately for the actor without waiting for the WS event. onIssueLabelsChanged(qc, wsId, issueId, data.labels); } }, From c381d59c7a9c7810361ad349c24d6b3587bcbc76 Mon Sep 17 00:00:00 2001 From: devv-eve Date: Tue, 28 Apr 2026 08:57:15 +0800 Subject: [PATCH 19/38] fix: preserve authored markdown links during linkify (#1761) Co-authored-by: Eve --- packages/ui/markdown/linkify.ts | 86 +++++++++++++++++++ .../editor/utils/preprocess-links.test.ts | 15 ++++ 2 files changed, 101 insertions(+) diff --git a/packages/ui/markdown/linkify.ts b/packages/ui/markdown/linkify.ts index 4db9749e99..c698264ca0 100644 --- a/packages/ui/markdown/linkify.ts +++ b/packages/ui/markdown/linkify.ts @@ -93,6 +93,88 @@ function isInsideCode(pos: number, ranges: CodeRange[]): boolean { return ranges.some((r) => pos >= r.start && pos < r.end) } +function isEscaped(text: string, index: number): boolean { + let slashCount = 0 + for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) { + slashCount++ + } + return slashCount % 2 === 1 +} + +function findMatchingBracket(text: string, openIndex: number): number { + let depth = 0 + + for (let i = openIndex; i < text.length; i++) { + if (isEscaped(text, i)) continue + + const char = text[i] + if (char === '[') { + depth++ + } else if (char === ']') { + depth-- + if (depth === 0) return i + } + } + + return -1 +} + +function findInlineLinkEnd(text: string, openParenIndex: number): number { + let depth = 0 + + for (let i = openParenIndex; i < text.length; i++) { + if (isEscaped(text, i)) continue + + const char = text[i] + if (char === '(') { + depth++ + } else if (char === ')') { + depth-- + if (depth === 0) return i + 1 + } + } + + return -1 +} + +/** + * Find existing markdown link/image spans so auto-linkification does not create + * nested links inside their labels or destinations. + */ +function findMarkdownLinkRanges(text: string): CodeRange[] { + const ranges: CodeRange[] = [] + + for (let i = 0; i < text.length; i++) { + if (text[i] !== '[' || isEscaped(text, i)) continue + if (ranges.some((r) => i >= r.start && i < r.end)) continue + + const labelEnd = findMatchingBracket(text, i) + if (labelEnd === -1) continue + + const start = i > 0 && text[i - 1] === '!' && !isEscaped(text, i - 1) ? i - 1 : i + const nextChar = text[labelEnd + 1] + + if (nextChar === '(') { + const end = findInlineLinkEnd(text, labelEnd + 1) + if (end !== -1) { + ranges.push({ start, end }) + i = end - 1 + } + continue + } + + if (nextChar === '[') { + const referenceEnd = findMatchingBracket(text, labelEnd + 1) + if (referenceEnd !== -1) { + ranges.push({ start, end: referenceEnd + 1 }) + i = referenceEnd + } + } + } + + return ranges +} + /** * Check if a link at given position is already a markdown link * Looks for patterns like [text](url) or [text][ref] @@ -216,6 +298,7 @@ export function preprocessLinks(text: string): string { } const codeRanges = findCodeRanges(text) + const markdownLinkRanges = findMarkdownLinkRanges(text) const links = detectLinks(text) if (links.length === 0) return text @@ -228,6 +311,9 @@ export function preprocessLinks(text: string): string { // Skip if inside code block if (isInsideCode(link.start, codeRanges)) continue + // Skip if this match is inside an existing markdown link or image. + if (markdownLinkRanges.some((range) => rangesOverlap(link, range))) continue + // Skip if already a markdown link if (isAlreadyLinked(text, link.start, link.end)) continue diff --git a/packages/views/editor/utils/preprocess-links.test.ts b/packages/views/editor/utils/preprocess-links.test.ts index 7310d1b209..ded8acab1b 100644 --- a/packages/views/editor/utils/preprocess-links.test.ts +++ b/packages/views/editor/utils/preprocess-links.test.ts @@ -77,4 +77,19 @@ describe("preprocessLinks — CJK punctuation boundary", () => { const input = "见 [link](https://example.com/x。)后文"; expect(preprocessLinks(input)).toBe(input); }); + + it("does not linkify fuzzy domains inside existing markdown link labels", () => { + const input = + "数据来源:[NBA.com Schedule](https://www.nba.com/schedule)、[NBC Insider](https://www.nbc.com/nbc-insider/every-nba-playoff-game-this-week-on-nbc-peacock-april-25-28)"; + + expect(preprocessLinks(input)).toBe(input); + }); + + it("still linkifies fuzzy domains outside existing markdown links", () => { + const input = "数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 NBA.com"; + + expect(preprocessLinks(input)).toBe( + "数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 [NBA.com](http://NBA.com)", + ); + }); }); From f864a07bd5df01ac7d187da9e7f331cc8208e568 Mon Sep 17 00:00:00 2001 From: devv-eve Date: Tue, 28 Apr 2026 14:29:01 +0800 Subject: [PATCH 20/38] feat: add server Prometheus metrics endpoint Add Prometheus metrics endpoint with local-bind listener support and baseline metrics collectors. --- .env.example | 5 ++ Dockerfile | 2 +- Makefile | 2 +- SELF_HOSTING_ADVANCED.md | 23 ++++++ docker-compose.selfhost.yml | 1 + server/cmd/server/main.go | 55 ++++++++++-- server/cmd/server/metrics_test.go | 23 ++++++ server/cmd/server/router.go | 12 +++ server/go.mod | 10 +++ server/go.sum | 35 ++++++++ server/internal/metrics/config.go | 35 ++++++++ server/internal/metrics/config_test.go | 27 ++++++ server/internal/metrics/db.go | 88 ++++++++++++++++++++ server/internal/metrics/db_test.go | 35 ++++++++ server/internal/metrics/http.go | 94 +++++++++++++++++++++ server/internal/metrics/http_test.go | 100 ++++++++++++++++++++++ server/internal/metrics/realtime.go | 101 +++++++++++++++++++++++ server/internal/metrics/realtime_test.go | 36 ++++++++ server/internal/metrics/registry.go | 59 +++++++++++++ server/internal/metrics/server.go | 29 +++++++ server/internal/metrics/server_test.go | 46 +++++++++++ 21 files changed, 811 insertions(+), 7 deletions(-) create mode 100644 server/cmd/server/metrics_test.go create mode 100644 server/internal/metrics/config.go create mode 100644 server/internal/metrics/config_test.go create mode 100644 server/internal/metrics/db.go create mode 100644 server/internal/metrics/db_test.go create mode 100644 server/internal/metrics/http.go create mode 100644 server/internal/metrics/http_test.go create mode 100644 server/internal/metrics/realtime.go create mode 100644 server/internal/metrics/realtime_test.go create mode 100644 server/internal/metrics/registry.go create mode 100644 server/internal/metrics/server.go create mode 100644 server/internal/metrics/server_test.go diff --git a/.env.example b/.env.example index 709663e2ff..32bd6bd948 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable # See SELF_HOSTING.md for the full login setup. APP_ENV= PORT=8080 +# Prometheus metrics are disabled by default. When enabled, bind to loopback +# unless you protect the listener with private networking, allowlists, or +# proxy auth. Do not expose this endpoint through the public app/API ingress. +# HTTP request metrics start accumulating only when this listener is enabled. +# METRICS_ADDR=127.0.0.1:9090 JWT_SECRET=change-me-in-production MULTICA_SERVER_URL=ws://localhost:8080/ws MULTICA_APP_URL=http://localhost:3000 diff --git a/Dockerfile b/Dockerfile index 2978968e53..cbb9f23060 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY server/ ./server/ # Build binaries ARG VERSION=dev ARG COMMIT=unknown -RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/server ./cmd/server +RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate diff --git a/Makefile b/Makefile index 40fb1ab5f7..6d64b17c5c 100644 --- a/Makefile +++ b/Makefile @@ -277,7 +277,7 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') build: ## Build the server, CLI, and migrate binaries into server/bin - cd server && go build -o bin/server ./cmd/server + cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server ./cmd/server cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica cd server && go build -o bin/migrate ./cmd/migrate diff --git a/SELF_HOSTING_ADVANCED.md b/SELF_HOSTING_ADVANCED.md index 7d86a56245..59625be5a3 100644 --- a/SELF_HOSTING_ADVANCED.md +++ b/SELF_HOSTING_ADVANCED.md @@ -79,6 +79,7 @@ The `Secure` flag on session cookies is derived automatically from the scheme of | Variable | Default | Description | |----------|---------|-------------| | `PORT` | `8080` | Backend server port | +| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` | | `FRONTEND_PORT` | `3000` | Frontend port | | `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins | | `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | @@ -308,6 +309,28 @@ dependency-aware readiness probes and external monitoring that should fail when the database is unavailable or migrations are not fully applied. `/healthz` is kept as an alias for operator familiarity. +## Prometheus Metrics + +The backend can expose Prometheus metrics on a separate management listener: + +```bash +METRICS_ADDR=127.0.0.1:9090 ./server/bin/server +curl http://127.0.0.1:9090/metrics +``` + +`METRICS_ADDR` is empty by default, so no metrics listener is started. The +public API port does not serve `/metrics`; keep it that way for internet-facing +deployments. HTTP request metrics start accumulating only after the metrics +listener is enabled. Metrics can reveal internal routes, traffic volume, +dependency state, and runtime health. + +For Docker or Kubernetes deployments, prefer a private scrape path: bind the +metrics listener to an internal interface and protect it with private +networking, allowlists, NetworkPolicy, or proxy authentication. If you bind +`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a +trusted network, for example a host-local mapping such as +`127.0.0.1:9090:9090`. + ## Upgrading ```bash diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index be381ee42c..4fee5c8224 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -40,6 +40,7 @@ services: environment: DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable PORT: "8080" + METRICS_ADDR: ${METRICS_ADDR:-} JWT_SECRET: ${JWT_SECRET:-change-me-in-production} FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 02fcaa20e1..201a5f7bd2 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -14,12 +14,18 @@ import ( "github.com/multica-ai/multica/server/internal/analytics" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/logger" + obsmetrics "github.com/multica-ai/multica/server/internal/metrics" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/redis/go-redis/v9" ) +var ( + version = "dev" + commit = "unknown" +) + func newNamedRedisClient(base *redis.Options, suffix string) *redis.Client { opts := *base opts.ClientName = redisClientName(opts.ClientName, suffix) @@ -233,7 +239,29 @@ func main() { registerActivityListeners(bus, queries) registerNotificationListeners(bus, queries) - r := NewRouter(pool, hub, bus, analyticsClient, storeRedis) + metricsConfig := obsmetrics.ConfigFromEnv() + var metricsServer *http.Server + var httpMetrics *obsmetrics.HTTPMetrics + if metricsConfig.Enabled() { + metricsRegistry := obsmetrics.NewRegistry(obsmetrics.RegistryOptions{ + Pool: pool, + Realtime: realtime.M, + Version: version, + Commit: commit, + }) + httpMetrics = metricsRegistry.HTTP + metricsServer = obsmetrics.NewServer(metricsConfig.Addr, metricsRegistry.Gatherer) + if !obsmetrics.IsLoopbackAddr(metricsConfig.Addr) { + slog.Warn( + "metrics listener is not loopback-only; restrict access with private networking, allowlists, or proxy auth", + "addr", metricsConfig.Addr, + ) + } + } + + r := NewRouterWithOptions(pool, hub, bus, analyticsClient, storeRedis, RouterOptions{ + HTTPMetrics: httpMetrics, + }) srv := &http.Server{ Addr: ":" + port, @@ -252,7 +280,15 @@ func main() { go runAutopilotScheduler(autopilotCtx, queries, autopilotSvc) go runDBStatsLogger(sweepCtx, pool) - // Graceful shutdown + if metricsServer != nil { + go func() { + slog.Info("metrics server starting", "addr", metricsConfig.Addr) + if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("metrics server disabled after startup error", "error", err) + } + }() + } + go func() { slog.Info("server starting", "port", port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -268,12 +304,21 @@ func main() { slog.Info("shutting down server") sweepCancel() autopilotCancel() - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { + apiShutdownCtx, apiShutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := srv.Shutdown(apiShutdownCtx); err != nil { + apiShutdownCancel() slog.Error("server forced to shutdown", "error", err) os.Exit(1) } + apiShutdownCancel() + + if metricsServer != nil { + metricsShutdownCtx, metricsShutdownCancel := context.WithTimeout(context.Background(), 3*time.Second) + if err := metricsServer.Shutdown(metricsShutdownCtx); err != nil { + slog.Error("metrics server forced to shutdown", "error", err) + } + metricsShutdownCancel() + } slog.Info("server stopped") } diff --git a/server/cmd/server/metrics_test.go b/server/cmd/server/metrics_test.go new file mode 100644 index 0000000000..5e06c99978 --- /dev/null +++ b/server/cmd/server/metrics_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/multica-ai/multica/server/internal/analytics" + "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/realtime" +) + +func TestMainRouterDoesNotExposePrometheusMetrics(t *testing.T) { + router := NewRouter(nil, realtime.NewHub(), events.New(), analytics.NoopClient{}, nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("main API /metrics status = %d, want %d", rec.Code, http.StatusNotFound) + } +} diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index d91cc67d8b..253f2367da 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -17,6 +17,7 @@ import ( "github.com/multica-ai/multica/server/internal/auth" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" + obsmetrics "github.com/multica-ai/multica/server/internal/metrics" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" @@ -62,6 +63,14 @@ func allowedOrigins() []string { // keeps the default in-memory stores which are fine for single-node dev and // tests. func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analyticsClient analytics.Client, rdb *redis.Client) chi.Router { + return NewRouterWithOptions(pool, hub, bus, analyticsClient, rdb, RouterOptions{}) +} + +type RouterOptions struct { + HTTPMetrics *obsmetrics.HTTPMetrics +} + +func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analyticsClient analytics.Client, rdb *redis.Client, opts RouterOptions) chi.Router { queries := db.New(pool) emailSvc := service.NewEmailService() @@ -97,6 +106,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics r.Use(chimw.RequestID) r.Use(middleware.ClientMetadata) r.Use(middleware.RequestLogger) + if opts.HTTPMetrics != nil { + r.Use(opts.HTTPMetrics.Middleware) + } r.Use(chimw.Recoverer) r.Use(middleware.ContentSecurityPolicy) origins := allowedOrigins() diff --git a/server/go.mod b/server/go.mod index 73fb2915f1..70920f7785 100644 --- a/server/go.mod +++ b/server/go.mod @@ -16,6 +16,7 @@ require ( github.com/jackc/pgx/v5 v5.8.0 github.com/lmittmann/tint v1.1.3 github.com/oklog/ulid/v2 v2.1.1 + github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.18.0 github.com/resend/resend-go/v2 v2.28.0 github.com/robfig/cron/v3 v3.0.1 @@ -38,14 +39,23 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/pflag v1.0.9 // indirect go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/server/go.sum b/server/go.sum index b5f8cd6140..10247cfdd1 100644 --- a/server/go.sum +++ b/server/go.sum @@ -38,6 +38,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -45,6 +47,7 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -56,6 +59,8 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -70,21 +75,41 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/resend/resend-go/v2 v2.28.0 h1:ttM1/VZR4fApBv3xI1TneSKi1pbfFsVrq7fXFlHKtj4= github.com/resend/resend-go/v2 v2.28.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -99,12 +124,22 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/internal/metrics/config.go b/server/internal/metrics/config.go new file mode 100644 index 0000000000..211aab1fa5 --- /dev/null +++ b/server/internal/metrics/config.go @@ -0,0 +1,35 @@ +package metrics + +import ( + "net" + "os" + "strings" +) + +type Config struct { + Addr string +} + +func ConfigFromEnv() Config { + return Config{Addr: strings.TrimSpace(os.Getenv("METRICS_ADDR"))} +} + +func (c Config) Enabled() bool { + return strings.TrimSpace(c.Addr) != "" +} + +func IsLoopbackAddr(addr string) bool { + host, _, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + host = strings.TrimSpace(addr) + } + host = strings.Trim(host, "[]") + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/server/internal/metrics/config_test.go b/server/internal/metrics/config_test.go new file mode 100644 index 0000000000..927a5c865f --- /dev/null +++ b/server/internal/metrics/config_test.go @@ -0,0 +1,27 @@ +package metrics + +import "testing" + +func TestIsLoopbackAddr(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {"127.0.0.1:9090", true}, + {"localhost:9090", true}, + {"[::1]:9090", true}, + {":9090", false}, + {"0.0.0.0:9090", false}, + {"10.0.0.5:9090", false}, + {"metrics.example.com:9090", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.addr, func(t *testing.T) { + if got := IsLoopbackAddr(tt.addr); got != tt.want { + t.Fatalf("IsLoopbackAddr(%q) = %v, want %v", tt.addr, got, tt.want) + } + }) + } +} diff --git a/server/internal/metrics/db.go b/server/internal/metrics/db.go new file mode 100644 index 0000000000..8a84588e2b --- /dev/null +++ b/server/internal/metrics/db.go @@ -0,0 +1,88 @@ +package metrics + +import ( + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" +) + +type DBCollector struct { + pool *pgxpool.Pool + + acquiredConns *prometheus.Desc + idleConns *prometheus.Desc + maxConns *prometheus.Desc + totalConns *prometheus.Desc + constructingConns *prometheus.Desc + acquireCount *prometheus.Desc + acquireDuration *prometheus.Desc + emptyAcquireCount *prometheus.Desc + emptyAcquireWaitTime *prometheus.Desc + canceledAcquireCount *prometheus.Desc + newConnsCount *prometheus.Desc + maxIdleDestroyCount *prometheus.Desc + maxLifetimeDestroyCnt *prometheus.Desc +} + +func NewDBCollector(pool *pgxpool.Pool) *DBCollector { + return &DBCollector{ + pool: pool, + + acquiredConns: newDBDesc("acquired_conns", "Currently acquired PostgreSQL connections."), + idleConns: newDBDesc("idle_conns", "Currently idle PostgreSQL connections."), + maxConns: newDBDesc("max_conns", "Maximum PostgreSQL connections allowed by the pool."), + totalConns: newDBDesc("total_conns", "Total PostgreSQL connections currently in the pool."), + constructingConns: newDBDesc("constructing_conns", "PostgreSQL connections currently being established."), + acquireCount: newDBDesc("acquire_count", "Total successful PostgreSQL connection acquires."), + acquireDuration: newDBDesc("acquire_duration_seconds_total", "Total time spent acquiring PostgreSQL connections."), + emptyAcquireCount: newDBDesc("empty_acquire_count", "Total acquires that waited because the PostgreSQL pool was empty."), + emptyAcquireWaitTime: newDBDesc("empty_acquire_wait_seconds_total", "Total time spent waiting for PostgreSQL connections when the pool was empty."), + canceledAcquireCount: newDBDesc("canceled_acquire_count", "Total canceled PostgreSQL connection acquires."), + newConnsCount: newDBDesc("new_conns_count", "Total PostgreSQL connections created by the pool."), + maxIdleDestroyCount: newDBDesc("max_idle_destroy_count", "Total PostgreSQL connections destroyed due to idle limits."), + maxLifetimeDestroyCnt: newDBDesc("max_lifetime_destroy_count", "Total PostgreSQL connections destroyed due to max lifetime."), + } +} + +func newDBDesc(name, help string) *prometheus.Desc { + return prometheus.NewDesc("multica_db_pool_"+name, help, nil, nil) +} + +func (c *DBCollector) Describe(ch chan<- *prometheus.Desc) { + for _, desc := range []*prometheus.Desc{ + c.acquiredConns, + c.idleConns, + c.maxConns, + c.totalConns, + c.constructingConns, + c.acquireCount, + c.acquireDuration, + c.emptyAcquireCount, + c.emptyAcquireWaitTime, + c.canceledAcquireCount, + c.newConnsCount, + c.maxIdleDestroyCount, + c.maxLifetimeDestroyCnt, + } { + ch <- desc + } +} + +func (c *DBCollector) Collect(ch chan<- prometheus.Metric) { + if c.pool == nil { + return + } + stat := c.pool.Stat() + ch <- prometheus.MustNewConstMetric(c.acquiredConns, prometheus.GaugeValue, float64(stat.AcquiredConns())) + ch <- prometheus.MustNewConstMetric(c.idleConns, prometheus.GaugeValue, float64(stat.IdleConns())) + ch <- prometheus.MustNewConstMetric(c.maxConns, prometheus.GaugeValue, float64(stat.MaxConns())) + ch <- prometheus.MustNewConstMetric(c.totalConns, prometheus.GaugeValue, float64(stat.TotalConns())) + ch <- prometheus.MustNewConstMetric(c.constructingConns, prometheus.GaugeValue, float64(stat.ConstructingConns())) + ch <- prometheus.MustNewConstMetric(c.acquireCount, prometheus.CounterValue, float64(stat.AcquireCount())) + ch <- prometheus.MustNewConstMetric(c.acquireDuration, prometheus.CounterValue, stat.AcquireDuration().Seconds()) + ch <- prometheus.MustNewConstMetric(c.emptyAcquireCount, prometheus.CounterValue, float64(stat.EmptyAcquireCount())) + ch <- prometheus.MustNewConstMetric(c.emptyAcquireWaitTime, prometheus.CounterValue, stat.EmptyAcquireWaitTime().Seconds()) + ch <- prometheus.MustNewConstMetric(c.canceledAcquireCount, prometheus.CounterValue, float64(stat.CanceledAcquireCount())) + ch <- prometheus.MustNewConstMetric(c.newConnsCount, prometheus.CounterValue, float64(stat.NewConnsCount())) + ch <- prometheus.MustNewConstMetric(c.maxIdleDestroyCount, prometheus.CounterValue, float64(stat.MaxIdleDestroyCount())) + ch <- prometheus.MustNewConstMetric(c.maxLifetimeDestroyCnt, prometheus.CounterValue, float64(stat.MaxLifetimeDestroyCount())) +} diff --git a/server/internal/metrics/db_test.go b/server/internal/metrics/db_test.go new file mode 100644 index 0000000000..2a0e6ca8c4 --- /dev/null +++ b/server/internal/metrics/db_test.go @@ -0,0 +1,35 @@ +package metrics + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestDBCollectorExposesPoolStats(t *testing.T) { + pool, err := pgxpool.New(context.Background(), "postgres://multica:multica@127.0.0.1:1/multica?sslmode=disable") + if err != nil { + t.Fatalf("create pool: %v", err) + } + defer pool.Close() + + registry := NewRegistry(RegistryOptions{Pool: pool}) + rec := httptest.NewRecorder() + NewHandler(registry.Gatherer).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + body := rec.Body.String() + + for _, want := range []string{ + "multica_db_pool_acquired_conns", + "multica_db_pool_idle_conns", + "multica_db_pool_max_conns", + "multica_db_pool_acquire_duration_seconds_total", + } { + if !strings.Contains(body, want) { + t.Fatalf("metrics body missing %q\n%s", want, body) + } + } +} diff --git a/server/internal/metrics/http.go b/server/internal/metrics/http.go new file mode 100644 index 0000000000..5e957f2eb9 --- /dev/null +++ b/server/internal/metrics/http.go @@ -0,0 +1,94 @@ +package metrics + +import ( + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + "github.com/prometheus/client_golang/prometheus" +) + +type HTTPMetrics struct { + requests *prometheus.CounterVec + duration *prometheus.HistogramVec + inFlight prometheus.Gauge +} + +func NewHTTPMetrics() *HTTPMetrics { + return &HTTPMetrics{ + requests: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "multica", + Subsystem: "http", + Name: "requests_total", + Help: "Total HTTP requests served by the API server.", + }, []string{"method", "route", "status"}), + duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "multica", + Subsystem: "http", + Name: "request_duration_seconds", + Help: "HTTP request duration observed by the API server.", + Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}, + }, []string{"method", "route", "status"}), + inFlight: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "multica", + Subsystem: "http", + Name: "in_flight_requests", + Help: "Current number of in-flight HTTP requests served by the API server.", + }), + } +} + +func (m *HTTPMetrics) Collectors() []prometheus.Collector { + return []prometheus.Collector{m.requests, m.duration, m.inFlight} +} + +func (m *HTTPMetrics) Middleware(next http.Handler) http.Handler { + if m == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isHealthProbePath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + m.inFlight.Inc() + defer m.inFlight.Dec() + + start := time.Now() + ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + + status := ww.Status() + if status == 0 { + status = http.StatusOK + } + labels := prometheus.Labels{ + "method": r.Method, + "route": routePattern(r), + "status": strconv.Itoa(status), + } + m.requests.With(labels).Inc() + m.duration.With(labels).Observe(time.Since(start).Seconds()) + }) +} + +func routePattern(r *http.Request) string { + if rctx := chi.RouteContext(r.Context()); rctx != nil { + if pattern := rctx.RoutePattern(); pattern != "" { + return pattern + } + } + return "unmatched" +} + +func isHealthProbePath(path string) bool { + switch path { + case "/health", "/healthz", "/readyz": + return true + default: + return false + } +} diff --git a/server/internal/metrics/http_test.go b/server/internal/metrics/http_test.go new file mode 100644 index 0000000000..e57d332fbf --- /dev/null +++ b/server/internal/metrics/http_test.go @@ -0,0 +1,100 @@ +package metrics + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestHTTPMiddlewareUsesRoutePatternLabels(t *testing.T) { + registry := NewRegistry(RegistryOptions{ + Version: "v-test", + Commit: "abc123", + }) + + r := chi.NewRouter() + r.Use(registry.HTTP.Middleware) + r.Get("/api/issues/{id}", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("ok")) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/issues/secret-issue-id?token=secret-token", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("request status = %d, want %d", rec.Code, http.StatusCreated) + } + + metricsRec := httptest.NewRecorder() + NewHandler(registry.Gatherer).ServeHTTP(metricsRec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + body := metricsRec.Body.String() + + for _, want := range []string{ + `multica_http_requests_total{method="GET",route="/api/issues/{id}",status="201"} 1`, + `multica_build_info{commit="abc123",version="v-test"} 1`, + } { + if !strings.Contains(body, want) { + t.Fatalf("metrics body missing %q\n%s", want, body) + } + } + for _, leaked := range []string{"secret-issue-id", "secret-token"} { + if strings.Contains(body, leaked) { + t.Fatalf("metrics body leaked %q\n%s", leaked, body) + } + } +} + +func TestMetricsHandlerOnlyServesMetricsPath(t *testing.T) { + registry := NewRegistry(RegistryOptions{}) + handler := NewHandler(registry.Gatherer) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("/metrics status = %d, want %d", rec.Code, http.StatusOK) + } + if body, _ := io.ReadAll(rec.Body); !strings.Contains(string(body), "multica_build_info") { + t.Fatalf("/metrics body missing build info: %s", body) + } + + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("/health status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestHTTPMiddlewareSkipsHealthProbePaths(t *testing.T) { + registry := NewRegistry(RegistryOptions{}) + + r := chi.NewRouter() + r.Use(registry.HTTP.Middleware) + r.Get("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + r.Get("/readyz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + for _, path := range []string{"/health", "/readyz"} { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusOK) + } + } + + metricsRec := httptest.NewRecorder() + NewHandler(registry.Gatherer).ServeHTTP(metricsRec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + body := metricsRec.Body.String() + for _, skippedRoute := range []string{`route="/health"`, `route="/readyz"`} { + if strings.Contains(body, skippedRoute) { + t.Fatalf("metrics body contains skipped health route %q\n%s", skippedRoute, body) + } + } +} diff --git a/server/internal/metrics/realtime.go b/server/internal/metrics/realtime.go new file mode 100644 index 0000000000..45fa7fe7f9 --- /dev/null +++ b/server/internal/metrics/realtime.go @@ -0,0 +1,101 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/multica-ai/multica/server/internal/realtime" +) + +type RealtimeCollector struct { + metrics *realtime.Metrics + + connectsTotal *prometheus.Desc + disconnectsTotal *prometheus.Desc + activeConnections *prometheus.Desc + slowEvictionsTotal *prometheus.Desc + messagesSentTotal *prometheus.Desc + messagesDropped *prometheus.Desc + redisConnected *prometheus.Desc + redisXAddTotal *prometheus.Desc + redisXAddErrors *prometheus.Desc + redisXReadTotal *prometheus.Desc + redisXReadErrors *prometheus.Desc + redisAckTotal *prometheus.Desc + redisMirrorErrors *prometheus.Desc + redisMirrorDiverged *prometheus.Desc +} + +func NewRealtimeCollector(m *realtime.Metrics) *RealtimeCollector { + return &RealtimeCollector{ + metrics: m, + + connectsTotal: newRealtimeDesc("connects_total", "Total realtime WebSocket connections opened."), + disconnectsTotal: newRealtimeDesc("disconnects_total", "Total realtime WebSocket connections closed."), + activeConnections: newRealtimeDesc("active_connections", "Current realtime WebSocket connections."), + slowEvictionsTotal: newRealtimeDesc("slow_evictions_total", "Total realtime clients evicted for slow consumption."), + messagesSentTotal: newRealtimeDesc("messages_sent_total", "Total realtime messages sent."), + messagesDropped: newRealtimeDesc("messages_dropped_total", "Total realtime messages dropped."), + redisConnected: newRealtimeDesc("redis_connected", "Whether the realtime Redis relay is connected."), + redisXAddTotal: newRealtimeDesc("redis_xadd_total", "Total Redis XADD operations by the realtime relay."), + redisXAddErrors: newRealtimeDesc("redis_xadd_errors_total", "Total Redis XADD errors by the realtime relay."), + redisXReadTotal: newRealtimeDesc("redis_xread_total", "Total Redis XREAD operations by the realtime relay."), + redisXReadErrors: newRealtimeDesc("redis_xread_errors_total", "Total Redis XREAD errors by the realtime relay."), + redisAckTotal: newRealtimeDesc("redis_ack_total", "Total Redis stream acknowledgements by the realtime relay."), + redisMirrorErrors: prometheus.NewDesc("multica_realtime_redis_mirror_errors_total", "Total Redis mirror write errors by the realtime relay.", []string{"target"}, nil), + redisMirrorDiverged: newRealtimeDesc("redis_mirror_divergence_total", "Total Redis mirror divergence events by the realtime relay."), + } +} + +func newRealtimeDesc(name, help string) *prometheus.Desc { + return prometheus.NewDesc("multica_realtime_"+name, help, nil, nil) +} + +func (c *RealtimeCollector) Describe(ch chan<- *prometheus.Desc) { + for _, desc := range []*prometheus.Desc{ + c.connectsTotal, + c.disconnectsTotal, + c.activeConnections, + c.slowEvictionsTotal, + c.messagesSentTotal, + c.messagesDropped, + c.redisConnected, + c.redisXAddTotal, + c.redisXAddErrors, + c.redisXReadTotal, + c.redisXReadErrors, + c.redisAckTotal, + c.redisMirrorErrors, + c.redisMirrorDiverged, + } { + ch <- desc + } +} + +func (c *RealtimeCollector) Collect(ch chan<- prometheus.Metric) { + if c.metrics == nil { + return + } + m := c.metrics + ch <- prometheus.MustNewConstMetric(c.connectsTotal, prometheus.CounterValue, float64(m.ConnectsTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.disconnectsTotal, prometheus.CounterValue, float64(m.DisconnectsTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.activeConnections, prometheus.GaugeValue, float64(m.ActiveConnections.Load())) + ch <- prometheus.MustNewConstMetric(c.slowEvictionsTotal, prometheus.CounterValue, float64(m.SlowEvictionsTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.messagesSentTotal, prometheus.CounterValue, float64(m.MessagesSentTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.messagesDropped, prometheus.CounterValue, float64(m.MessagesDroppedTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.redisConnected, prometheus.GaugeValue, boolFloat(m.RedisConnected.Load())) + ch <- prometheus.MustNewConstMetric(c.redisXAddTotal, prometheus.CounterValue, float64(m.RedisXAddTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.redisXAddErrors, prometheus.CounterValue, float64(m.RedisXAddErrors.Load())) + ch <- prometheus.MustNewConstMetric(c.redisXReadTotal, prometheus.CounterValue, float64(m.RedisXReadTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.redisXReadErrors, prometheus.CounterValue, float64(m.RedisXReadErrors.Load())) + ch <- prometheus.MustNewConstMetric(c.redisAckTotal, prometheus.CounterValue, float64(m.RedisAckTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.redisMirrorErrors, prometheus.CounterValue, float64(m.RedisMirrorPrimaryErrors.Load()), "primary") + ch <- prometheus.MustNewConstMetric(c.redisMirrorErrors, prometheus.CounterValue, float64(m.RedisMirrorSecondaryErrors.Load()), "secondary") + ch <- prometheus.MustNewConstMetric(c.redisMirrorDiverged, prometheus.CounterValue, float64(m.RedisMirrorDivergenceTotal.Load())) +} + +func boolFloat(v bool) float64 { + if v { + return 1 + } + return 0 +} diff --git a/server/internal/metrics/realtime_test.go b/server/internal/metrics/realtime_test.go new file mode 100644 index 0000000000..7799e27e06 --- /dev/null +++ b/server/internal/metrics/realtime_test.go @@ -0,0 +1,36 @@ +package metrics + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/multica-ai/multica/server/internal/realtime" +) + +func TestRealtimeCollectorExposesCounters(t *testing.T) { + m := &realtime.Metrics{} + m.ActiveConnections.Store(3) + m.MessagesSentTotal.Store(11) + m.RedisConnected.Store(true) + m.RedisMirrorPrimaryErrors.Store(2) + m.RedisMirrorSecondaryErrors.Store(5) + + registry := NewRegistry(RegistryOptions{Realtime: m}) + rec := httptest.NewRecorder() + NewHandler(registry.Gatherer).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + body := rec.Body.String() + + for _, want := range []string{ + "multica_realtime_active_connections 3", + "multica_realtime_messages_sent_total 11", + "multica_realtime_redis_connected 1", + `multica_realtime_redis_mirror_errors_total{target="primary"} 2`, + `multica_realtime_redis_mirror_errors_total{target="secondary"} 5`, + } { + if !strings.Contains(body, want) { + t.Fatalf("metrics body missing %q\n%s", want, body) + } + } +} diff --git a/server/internal/metrics/registry.go b/server/internal/metrics/registry.go new file mode 100644 index 0000000000..4b03e83110 --- /dev/null +++ b/server/internal/metrics/registry.go @@ -0,0 +1,59 @@ +package metrics + +import ( + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + + "github.com/multica-ai/multica/server/internal/realtime" +) + +type RegistryOptions struct { + Pool *pgxpool.Pool + Realtime *realtime.Metrics + Version string + Commit string +} + +type Registry struct { + Gatherer prometheus.Gatherer + HTTP *HTTPMetrics +} + +func NewRegistry(opts RegistryOptions) *Registry { + reg := prometheus.NewRegistry() + reg.MustRegister(collectors.NewGoCollector()) + reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + + buildInfo := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "multica_build_info", + Help: "Build information for the Multica server binary.", + }, []string{"version", "commit"}) + buildInfo.WithLabelValues(defaultLabel(opts.Version, "dev"), defaultLabel(opts.Commit, "unknown")).Set(1) + reg.MustRegister(buildInfo) + + httpMetrics := NewHTTPMetrics() + reg.MustRegister(httpMetrics.Collectors()...) + + if opts.Pool != nil { + reg.MustRegister(NewDBCollector(opts.Pool)) + } + if opts.Realtime != nil { + reg.MustRegister(NewRealtimeCollector(opts.Realtime)) + } + + return &Registry{ + Gatherer: reg, + HTTP: httpMetrics, + } +} + +func defaultLabel(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} diff --git a/server/internal/metrics/server.go b/server/internal/metrics/server.go new file mode 100644 index 0000000000..d8fefc78b0 --- /dev/null +++ b/server/internal/metrics/server.go @@ -0,0 +1,29 @@ +package metrics + +import ( + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func NewHandler(gatherer prometheus.Gatherer) http.Handler { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.HandlerFor(gatherer, promhttp.HandlerOpts{ + EnableOpenMetrics: true, + ErrorHandling: promhttp.HTTPErrorOnError, + })) + return mux +} + +func NewServer(addr string, gatherer prometheus.Gatherer) *http.Server { + return &http.Server{ + Addr: addr, + Handler: NewHandler(gatherer), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } +} diff --git a/server/internal/metrics/server_test.go b/server/internal/metrics/server_test.go new file mode 100644 index 0000000000..fe3a61180e --- /dev/null +++ b/server/internal/metrics/server_test.go @@ -0,0 +1,46 @@ +package metrics + +import ( + "context" + "io" + "net" + "net/http" + "strings" + "testing" + "time" +) + +func TestMetricsServerCanBindLoopback(t *testing.T) { + registry := NewRegistry(RegistryOptions{}) + server := NewServer("127.0.0.1:0", registry.Gatherer) + ln, err := net.Listen("tcp", server.Addr) + if err != nil { + t.Fatalf("listen: %v", err) + } + + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve(ln) + }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = server.Shutdown(ctx) + if err := <-errCh; err != nil && err != http.ErrServerClosed { + t.Fatalf("serve: %v", err) + } + }) + + resp, err := http.Get("http://" + ln.Addr().String() + "/metrics") + if err != nil { + t.Fatalf("get /metrics: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("/metrics status = %d, want %d", resp.StatusCode, http.StatusOK) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "multica_build_info") { + t.Fatalf("/metrics body missing build info: %s", body) + } +} From f628e4877556025ec08040f15466ff00b55f3bd0 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:50:28 +0800 Subject: [PATCH 21/38] refactor(server): error-returning ParseUUID to prevent silent data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(server): make ParseUUID error-returning to prevent silent data loss (MUL-1410) util.ParseUUID previously swallowed errors and returned a zero pgtype.UUID on invalid input. When this zero UUID reached a write query (DELETE/UPDATE), the SQL matched zero rows and the handler returned 2xx success — producing silent data corruption. #1661 (DeleteIssue with identifier-style ID) was the visible symptom; PR #1680 patched that one site, this commit closes the class of bug. Changes: - util.ParseUUID now returns (pgtype.UUID, error). Add util.MustParseUUID for trusted round-trips that should panic on invalid input. - handler/handler.go: parseUUID wrapper now calls MustParseUUID — any unguarded user-input string reaching it surfaces as a recovered panic (chi middleware.Recoverer → 500) instead of silently corrupting data. Add parseUUIDOrBadRequest(w, s, fieldName) for handler entry points. - Convert every Queries.Delete*/Update* call site reachable from raw user input (autopilot, comment, project, skill, skill_file, label, pin, attachment, feedback, issue assignee, daemon runtime, workspace) to validate UUIDs explicitly with parseUUIDOrBadRequest, returning 400 on invalid input. Where a resolved entity.ID is already in scope, write queries now use it directly instead of re-parsing the URL string. - Update getWorkspaceMember + loadIssueForUser to handle invalid UUIDs gracefully (404/400 instead of panic). - Update util/middleware/cmd-level callers (subscriber_listeners, notification_listeners, activity_listeners, scope_authorizer, middleware/workspace) to use the error-returning API. - Add server/internal/util/pgx_test.go covering valid/invalid input and the MustParseUUID panic contract. - Add TestDeleteIssueByIdentifier + TestDeleteIssueRejectsInvalidUUID regression tests in handler_test.go (the original #1661 bug + the invalid-input case). - Document the handler UUID parsing convention in CLAUDE.md so the rule is enforceable in future PR review. * fix(server): address GPT-Boy review of #1748 P1 fixes from PR #1748 review: 1. Migrate remaining request-boundary UUIDs to parseUUIDOrBadRequest so malformed input returns 400 instead of panic/500. Was missing on: - issue.go: workspace_id in CreateIssue/ChildIssueProgress/ListIssues/ SearchIssues/BatchUpdateIssues/BatchDeleteIssues; project_id / parent_issue_id / lead_id / assignee_id / assignee_ids / creator_id filters; batch issue_ids and assignee/parent/project fields in BatchUpdateIssues (skip on bad input via util.ParseUUID, matching the existing per-row continue semantics). - project.go: project id + workspace_id in GetProject/UpdateProject/ DeleteProject; lead_id in CreateProject/UpdateProject; workspace_id in ListProjects + SearchProjects. - handler.go: resolveActor now uses util.ParseUUID for X-Agent-ID / X-Task-ID headers; invalid UUID falls back to "member" (matches pre-existing semantics) instead of panicking. - issue.go: validateAssigneePair returns 400 on invalid workspace_id instead of panicking. 2. Fix issue:deleted WS event payloads to emit uuidToString(issue.ID) instead of the raw URL string. After an identifier-path delete ("MUL-7"), the previous payload would have leaked the identifier to subscribers, leaving stale entries in frontend caches that key by UUID. Updated DeleteIssue (issue.go:1341) and BatchDeleteIssues (issue.go:1641). The slog "issue deleted" log line also now records the resolved UUID so logs match the WS payload. 3. Extend TestDeleteIssueByIdentifier to subscribe to the bus and assert issue:deleted.payload.issue_id is the resolved UUID, not the identifier. * fix(server): validate remaining reviewed UUID inputs * fix(server): validate remaining handler UUID inputs * fix(server): finish request boundary UUID audit * fix(server): validate remaining request body UUIDs * fix(server): validate runtime path UUIDs * fix(server): validate remaining audit UUID inputs --------- Co-authored-by: Eve --- CLAUDE.md | 11 + server/cmd/server/activity_listeners_test.go | 2 +- .../cmd/server/notification_listeners_test.go | 4 +- server/cmd/server/router.go | 9 +- server/cmd/server/scope_authorizer.go | 18 +- .../cmd/server/subscriber_listeners_test.go | 25 +- server/internal/handler/agent.go | 38 +- server/internal/handler/autopilot.go | 154 ++++--- server/internal/handler/chat.go | 138 +++--- server/internal/handler/comment.go | 68 ++- server/internal/handler/daemon.go | 42 +- server/internal/handler/feedback.go | 9 +- server/internal/handler/file.go | 54 ++- server/internal/handler/handler.go | 136 +++++- server/internal/handler/handler_test.go | 396 +++++++++++++++++- server/internal/handler/inbox.go | 46 +- server/internal/handler/invitation.go | 56 ++- server/internal/handler/issue.go | 174 ++++++-- server/internal/handler/label.go | 100 +++-- server/internal/handler/onboarding.go | 41 +- .../internal/handler/personal_access_token.go | 18 +- server/internal/handler/pin.go | 45 +- server/internal/handler/project.go | 68 ++- server/internal/handler/reaction.go | 28 +- server/internal/handler/runtime.go | 27 +- .../internal/handler/runtime_local_skills.go | 37 +- server/internal/handler/runtime_models.go | 8 +- server/internal/handler/runtime_test.go | 60 ++- server/internal/handler/runtime_update.go | 8 +- server/internal/handler/skill.go | 43 +- server/internal/handler/skill_create.go | 9 +- server/internal/handler/workspace.go | 57 ++- server/internal/middleware/workspace.go | 14 +- server/internal/util/pgx.go | 29 +- server/internal/util/pgx_test.go | 47 +++ 35 files changed, 1561 insertions(+), 458 deletions(-) create mode 100644 server/internal/util/pgx_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1e5fc6218d..1718a86701 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,17 @@ make start-worktree # Start using .env.worktree - Avoid broad refactors unless required by the task. - New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree. +### Backend Handler UUID Parsing Convention + +Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows. + +- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query. +- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately. +- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running. +- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error. + +When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first. + ### Package Boundary Rules These are hard constraints. Violating them breaks the cross-platform architecture: diff --git a/server/cmd/server/activity_listeners_test.go b/server/cmd/server/activity_listeners_test.go index aea726de23..197b90b51a 100644 --- a/server/cmd/server/activity_listeners_test.go +++ b/server/cmd/server/activity_listeners_test.go @@ -16,7 +16,7 @@ import ( func listActivitiesForIssue(t *testing.T, queries *db.Queries, issueID string) []db.ActivityLog { t.Helper() activities, err := queries.ListActivities(context.Background(), db.ListActivitiesParams{ - IssueID: util.ParseUUID(issueID), + IssueID: util.MustParseUUID(issueID), Limit: 100, Offset: 0, }) diff --git a/server/cmd/server/notification_listeners_test.go b/server/cmd/server/notification_listeners_test.go index 031cba85b1..aff61f3515 100644 --- a/server/cmd/server/notification_listeners_test.go +++ b/server/cmd/server/notification_listeners_test.go @@ -18,9 +18,9 @@ import ( func inboxItemsForRecipient(t *testing.T, queries *db.Queries, recipientID string) []db.ListInboxItemsRow { t.Helper() items, err := queries.ListInboxItems(context.Background(), db.ListInboxItemsParams{ - WorkspaceID: util.ParseUUID(testWorkspaceID), + WorkspaceID: util.MustParseUUID(testWorkspaceID), RecipientType: "member", - RecipientID: util.ParseUUID(recipientID), + RecipientID: util.MustParseUUID(recipientID), }) if err != nil { t.Fatalf("ListInboxItems: %v", err) diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 253f2367da..a948501d94 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -481,12 +481,11 @@ func (pr *patResolver) ResolveToken(ctx context.Context, token string) (string, return util.UUIDToString(pat.UserID), true } +// parseUUID is a thin alias for util.MustParseUUID. Call sites here are all +// internal round-trips of DB-sourced UUIDs (e.g. issue.ID, e.ActorID), so an +// invalid value indicates a programming error and should panic loudly. func parseUUID(s string) pgtype.UUID { - var u pgtype.UUID - if err := u.Scan(s); err != nil { - return pgtype.UUID{} - } - return u + return util.MustParseUUID(s) } func splitAndTrim(s string) []string { diff --git a/server/cmd/server/scope_authorizer.go b/server/cmd/server/scope_authorizer.go index 11e7f551e7..c8033a5262 100644 --- a/server/cmd/server/scope_authorizer.go +++ b/server/cmd/server/scope_authorizer.go @@ -5,6 +5,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/realtime" + "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -31,9 +32,12 @@ func (a *dbScopeAuthorizer) AuthorizeScope(ctx context.Context, userID, workspac if workspaceID == "" || scopeID == "" { return false, nil } - wsUUID := parseUUID(workspaceID) - idUUID := parseUUID(scopeID) - if !wsUUID.Valid || !idUUID.Valid { + wsUUID, err := util.ParseUUID(workspaceID) + if err != nil { + return false, nil + } + idUUID, err := util.ParseUUID(scopeID) + if err != nil { return false, nil } switch scopeType { @@ -60,8 +64,8 @@ func (a *dbScopeAuthorizer) AuthorizeScope(ctx context.Context, userID, workspac if sess.WorkspaceID != wsUUID { return false, nil } - uidUUID := parseUUID(userID) - if !uidUUID.Valid || sess.CreatorID != uidUUID { + uidUUID, err := util.ParseUUID(userID) + if err != nil || sess.CreatorID != uidUUID { return false, nil } return true, nil @@ -81,8 +85,8 @@ func (a *dbScopeAuthorizer) AuthorizeScope(ctx context.Context, userID, workspac // otherwise any workspace member who learns a session_id could // subscribe to chat:message / chat:done / chat:session_read for a // peer's private chat. - uidUUID := parseUUID(userID) - if !uidUUID.Valid || sess.CreatorID != uidUUID { + uidUUID, err := util.ParseUUID(userID) + if err != nil || sess.CreatorID != uidUUID { return false, nil } return true, nil diff --git a/server/cmd/server/subscriber_listeners_test.go b/server/cmd/server/subscriber_listeners_test.go index 64565315a9..871068004b 100644 --- a/server/cmd/server/subscriber_listeners_test.go +++ b/server/cmd/server/subscriber_listeners_test.go @@ -4,7 +4,6 @@ import ( "context" "testing" - "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" "github.com/multica-ai/multica/server/internal/util" @@ -64,9 +63,9 @@ func cleanupTestUser(t *testing.T, email string) { func isSubscribed(t *testing.T, queries *db.Queries, issueID, userType, userID string) bool { t.Helper() subscribed, err := queries.IsIssueSubscriber(context.Background(), db.IsIssueSubscriberParams{ - IssueID: util.ParseUUID(issueID), + IssueID: util.MustParseUUID(issueID), UserType: userType, - UserID: util.ParseUUID(userID), + UserID: util.MustParseUUID(userID), }) if err != nil { t.Fatalf("IsIssueSubscriber: %v", err) @@ -76,7 +75,7 @@ func isSubscribed(t *testing.T, queries *db.Queries, issueID, userType, userID s func subscriberCount(t *testing.T, queries *db.Queries, issueID string) int { t.Helper() - subs, err := queries.ListIssueSubscribers(context.Background(), util.ParseUUID(issueID)) + subs, err := queries.ListIssueSubscribers(context.Background(), util.MustParseUUID(issueID)) if err != nil { t.Fatalf("ListIssueSubscribers: %v", err) } @@ -394,11 +393,13 @@ func TestSubscriberIssueCreated_AutopilotMapPayload(t *testing.T) { } } -// Verify parseUUID is consistent — pgtype.UUID from our local helper should match util.ParseUUID +// Verify parseUUID is consistent — the local helper should agree with util.MustParseUUID +// for valid input, and panic on invalid input (the silent-zero behavior was removed +// after #1661 to prevent silent SQL writes against a zero UUID). func TestParseUUIDConsistency(t *testing.T) { uuid := "550e8400-e29b-41d4-a716-446655440000" local := parseUUID(uuid) - utilResult := util.ParseUUID(uuid) + utilResult := util.MustParseUUID(uuid) if local != utilResult { t.Fatalf("parseUUID inconsistency: local=%v, util=%v", local, utilResult) } @@ -406,9 +407,11 @@ func TestParseUUIDConsistency(t *testing.T) { t.Fatal("expected valid UUID") } - // Empty string should produce invalid UUID - empty := parseUUID("") - if empty != (pgtype.UUID{}) { - t.Fatalf("expected zero UUID for empty string, got %v", empty) - } + // Invalid input (empty string) must panic now — never silently return a zero UUID. + defer func() { + if r := recover(); r == nil { + t.Fatal("expected parseUUID(\"\") to panic, but it returned normally") + } + }() + _ = parseUUID("") } diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index d96f737041..656dae5290 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -354,9 +354,18 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { req.MaxConcurrentTasks = 6 } + runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{ - ID: parseUUID(req.RuntimeID), - WorkspaceID: parseUUID(workspaceID), + ID: runtimeUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusBadRequest, "invalid runtime_id") @@ -369,7 +378,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { // list fails we fall through with isFirstAgent=false rather than // blocking creation, since the primary DB operation is the insert. isFirstAgent := false - if existing, listErr := h.Queries.ListAgents(r.Context(), parseUUID(workspaceID)); listErr == nil { + if existing, listErr := h.Queries.ListAgents(r.Context(), wsUUID); listErr == nil { isFirstAgent = len(existing) == 0 } @@ -394,7 +403,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { } agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, Name: req.Name, Description: req.Description, Instructions: req.Instructions, @@ -529,7 +538,7 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) { } params := db.UpdateAgentParams{ - ID: parseUUID(id), + ID: agent.ID, } if req.Name != nil { params.Name = pgtype.Text{String: *req.Name, Valid: true} @@ -561,8 +570,12 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) { params.McpConfig = append([]byte(nil), rawMcpConfig...) } if req.RuntimeID != nil { + runtimeUUID, ok := parseUUIDOrBadRequest(w, *req.RuntimeID, "runtime_id") + if !ok { + return + } runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{ - ID: parseUUID(*req.RuntimeID), + ID: runtimeUUID, WorkspaceID: agent.WorkspaceID, }) if err != nil { @@ -595,7 +608,7 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) { // mcp_config: null in the request means explicitly clear the field. // COALESCE in UpdateAgent cannot set a column to NULL, so we use a dedicated query. if shouldClearMcpConfig { - agent, err = h.Queries.ClearAgentMcpConfig(r.Context(), parseUUID(id)) + agent, err = h.Queries.ClearAgentMcpConfig(r.Context(), agent.ID) if err != nil { slog.Warn("clear agent mcp_config failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...) writeError(w, http.StatusInternalServerError, "failed to clear mcp_config: "+err.Error()) @@ -627,7 +640,7 @@ func (h *Handler) ArchiveAgent(w http.ResponseWriter, r *http.Request) { userID := requestUserID(r) archived, err := h.Queries.ArchiveAgent(r.Context(), db.ArchiveAgentParams{ - ID: parseUUID(id), + ID: agent.ID, ArchivedBy: parseUUID(userID), }) if err != nil { @@ -637,7 +650,7 @@ func (h *Handler) ArchiveAgent(w http.ResponseWriter, r *http.Request) { } // Cancel all pending/active tasks for this agent. - if err := h.Queries.CancelAgentTasksByAgent(r.Context(), parseUUID(id)); err != nil { + if err := h.Queries.CancelAgentTasksByAgent(r.Context(), agent.ID); err != nil { slog.Warn("cancel agent tasks on archive failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...) } @@ -663,7 +676,7 @@ func (h *Handler) RestoreAgent(w http.ResponseWriter, r *http.Request) { return } - restored, err := h.Queries.RestoreAgent(r.Context(), parseUUID(id)) + restored, err := h.Queries.RestoreAgent(r.Context(), agent.ID) if err != nil { slog.Warn("restore agent failed", append(logger.RequestAttrs(r), "error", err, "agent_id", id)...) writeError(w, http.StatusInternalServerError, "failed to restore agent") @@ -681,11 +694,12 @@ func (h *Handler) RestoreAgent(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - if _, ok := h.loadAgentForUser(w, r, id); !ok { + agent, ok := h.loadAgentForUser(w, r, id) + if !ok { return } - tasks, err := h.Queries.ListAgentTasks(r.Context(), parseUUID(id)) + tasks, err := h.Queries.ListAgentTasks(r.Context(), agent.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list agent tasks") return diff --git a/server/internal/handler/autopilot.go b/server/internal/handler/autopilot.go index 79891d9894..514c927443 100644 --- a/server/internal/handler/autopilot.go +++ b/server/internal/handler/autopilot.go @@ -194,12 +194,8 @@ func (h *Handler) GetAutopilot(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) - autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ - ID: parseUUID(id), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "autopilot not found") + autopilot, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID) + if !ok { return } @@ -221,6 +217,27 @@ func (h *Handler) GetAutopilot(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) loadAutopilotInWorkspace(w http.ResponseWriter, r *http.Request, autopilotID, workspaceID string) (db.Autopilot, bool) { + autopilotUUID, ok := parseUUIDOrBadRequest(w, autopilotID, "autopilot id") + if !ok { + return db.Autopilot{}, false + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return db.Autopilot{}, false + } + + autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ + ID: autopilotUUID, + WorkspaceID: wsUUID, + }) + if err != nil { + writeError(w, http.StatusNotFound, "autopilot not found") + return db.Autopilot{}, false + } + return autopilot, true +} + func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) { var req CreateAutopilotRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -250,10 +267,19 @@ func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) { return } + assigneeUUID, ok := parseUUIDOrBadRequest(w, req.AssigneeID, "assignee_id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + // Validate assignee is an agent in the workspace. _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{ - ID: parseUUID(req.AssigneeID), - WorkspaceID: parseUUID(workspaceID), + ID: assigneeUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace") @@ -261,9 +287,9 @@ func (h *Handler) CreateAutopilot(w http.ResponseWriter, r *http.Request) { } autopilot, err := h.Queries.CreateAutopilot(r.Context(), db.CreateAutopilotParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, Title: req.Title, - AssigneeID: parseUUID(req.AssigneeID), + AssigneeID: assigneeUUID, Status: "active", ExecutionMode: req.ExecutionMode, CreatedByType: "member", @@ -285,12 +311,8 @@ func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) - prev, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ - ID: parseUUID(id), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "autopilot not found") + prev, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID) + if !ok { return } @@ -335,14 +357,18 @@ func (h *Handler) UpdateAutopilot(w http.ResponseWriter, r *http.Request) { } if _, ok := rawFields["assignee_id"]; ok { if req.AssigneeID != nil { + assigneeUUID, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id") + if !ok { + return + } if _, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{ - ID: parseUUID(*req.AssigneeID), - WorkspaceID: parseUUID(workspaceID), + ID: assigneeUUID, + WorkspaceID: prev.WorkspaceID, }); err != nil { writeError(w, http.StatusBadRequest, "assignee must be a valid agent in this workspace") return } - params.AssigneeID = parseUUID(*req.AssigneeID) + params.AssigneeID = assigneeUUID } } @@ -361,9 +387,18 @@ func (h *Handler) DeleteAutopilot(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) + idUUID, ok := parseUUIDOrBadRequest(w, id, "autopilot id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ - ID: parseUUID(id), - WorkspaceID: parseUUID(workspaceID), + ID: idUUID, + WorkspaceID: wsUUID, }); err != nil { writeError(w, http.StatusNotFound, "autopilot not found") return @@ -374,12 +409,12 @@ func (h *Handler) DeleteAutopilot(w http.ResponseWriter, r *http.Request) { return } - if err := h.Queries.DeleteAutopilot(r.Context(), parseUUID(id)); err != nil { + if err := h.Queries.DeleteAutopilot(r.Context(), idUUID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete autopilot") return } - h.publish(protocol.EventAutopilotDeleted, workspaceID, "member", userID, map[string]any{"autopilot_id": id}) + h.publish(protocol.EventAutopilotDeleted, workspaceID, "member", userID, map[string]any{"autopilot_id": uuidToString(idUUID)}) w.WriteHeader(http.StatusNoContent) } @@ -389,12 +424,8 @@ func (h *Handler) CreateAutopilotTrigger(w http.ResponseWriter, r *http.Request) autopilotID := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) - ap, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ - ID: parseUUID(autopilotID), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "autopilot not found") + ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID) + if !ok { return } @@ -454,7 +485,7 @@ func (h *Handler) CreateAutopilotTrigger(w http.ResponseWriter, r *http.Request) resp := triggerToResponse(trigger) userID, _ := requireUserID(w, r) h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{ - "autopilot_id": autopilotID, + "autopilot_id": uuidToString(ap.ID), "trigger": resp, }) writeJSON(w, http.StatusCreated, resp) @@ -465,17 +496,18 @@ func (h *Handler) UpdateAutopilotTrigger(w http.ResponseWriter, r *http.Request) triggerID := chi.URLParam(r, "triggerId") workspaceID := h.resolveWorkspaceID(r) - // Verify autopilot belongs to workspace. - if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ - ID: parseUUID(autopilotID), - WorkspaceID: parseUUID(workspaceID), - }); err != nil { - writeError(w, http.StatusNotFound, "autopilot not found") + ap, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID) + if !ok { return } - prev, err := h.Queries.GetAutopilotTrigger(r.Context(), parseUUID(triggerID)) - if err != nil || uuidToString(prev.AutopilotID) != autopilotID { + triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id") + if !ok { + return + } + + prev, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID) + if err != nil || uuidToString(prev.AutopilotID) != uuidToString(ap.ID) { writeError(w, http.StatusNotFound, "trigger not found") return } @@ -542,7 +574,7 @@ func (h *Handler) UpdateAutopilotTrigger(w http.ResponseWriter, r *http.Request) resp := triggerToResponse(trigger) userID, _ := requireUserID(w, r) h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{ - "autopilot_id": autopilotID, + "autopilot_id": uuidToString(ap.ID), "trigger": resp, }) writeJSON(w, http.StatusOK, resp) @@ -553,16 +585,29 @@ func (h *Handler) DeleteAutopilotTrigger(w http.ResponseWriter, r *http.Request) triggerID := chi.URLParam(r, "triggerId") workspaceID := h.resolveWorkspaceID(r) + autopilotUUID, ok := parseUUIDOrBadRequest(w, autopilotID, "autopilot id") + if !ok { + return + } + triggerUUID, ok := parseUUIDOrBadRequest(w, triggerID, "trigger id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ - ID: parseUUID(autopilotID), - WorkspaceID: parseUUID(workspaceID), + ID: autopilotUUID, + WorkspaceID: wsUUID, }); err != nil { writeError(w, http.StatusNotFound, "autopilot not found") return } - trigger, err := h.Queries.GetAutopilotTrigger(r.Context(), parseUUID(triggerID)) - if err != nil || uuidToString(trigger.AutopilotID) != autopilotID { + trigger, err := h.Queries.GetAutopilotTrigger(r.Context(), triggerUUID) + if err != nil || uuidToString(trigger.AutopilotID) != uuidToString(autopilotUUID) { writeError(w, http.StatusNotFound, "trigger not found") return } @@ -572,14 +617,14 @@ func (h *Handler) DeleteAutopilotTrigger(w http.ResponseWriter, r *http.Request) return } - if err := h.Queries.DeleteAutopilotTrigger(r.Context(), parseUUID(triggerID)); err != nil { + if err := h.Queries.DeleteAutopilotTrigger(r.Context(), triggerUUID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete trigger") return } h.publish(protocol.EventAutopilotUpdated, workspaceID, "member", userID, map[string]any{ - "autopilot_id": autopilotID, - "trigger_id": triggerID, + "autopilot_id": uuidToString(autopilotUUID), + "trigger_id": uuidToString(triggerUUID), }) w.WriteHeader(http.StatusNoContent) } @@ -590,11 +635,8 @@ func (h *Handler) ListAutopilotRuns(w http.ResponseWriter, r *http.Request) { autopilotID := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) - if _, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ - ID: parseUUID(autopilotID), - WorkspaceID: parseUUID(workspaceID), - }); err != nil { - writeError(w, http.StatusNotFound, "autopilot not found") + autopilot, ok := h.loadAutopilotInWorkspace(w, r, autopilotID, workspaceID) + if !ok { return } @@ -615,7 +657,7 @@ func (h *Handler) ListAutopilotRuns(w http.ResponseWriter, r *http.Request) { } runs, err := h.Queries.ListAutopilotRuns(r.Context(), db.ListAutopilotRunsParams{ - AutopilotID: parseUUID(autopilotID), + AutopilotID: autopilot.ID, Limit: limit, Offset: offset, }) @@ -637,12 +679,8 @@ func (h *Handler) TriggerAutopilot(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) - autopilot, err := h.Queries.GetAutopilotInWorkspace(r.Context(), db.GetAutopilotInWorkspaceParams{ - ID: parseUUID(id), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "autopilot not found") + autopilot, ok := h.loadAutopilotInWorkspace(w, r, id, workspaceID) + if !ok { return } if autopilot.Status != "active" { diff --git a/server/internal/handler/chat.go b/server/internal/handler/chat.go index 1e880508d7..292f968af5 100644 --- a/server/internal/handler/chat.go +++ b/server/internal/handler/chat.go @@ -35,11 +35,19 @@ func (h *Handler) CreateChatSession(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "agent_id is required") return } + agentID, ok := parseUUIDOrBadRequest(w, req.AgentID, "agent_id") + if !ok { + return + } + workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } // Verify agent exists in workspace. agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{ - ID: parseUUID(req.AgentID), - WorkspaceID: parseUUID(workspaceID), + ID: agentID, + WorkspaceID: workspaceUUID, }) if err != nil { writeError(w, http.StatusNotFound, "agent not found") @@ -51,8 +59,8 @@ func (h *Handler) CreateChatSession(w http.ResponseWriter, r *http.Request) { } session, err := h.Queries.CreateChatSession(r.Context(), db.CreateChatSessionParams{ - WorkspaceID: parseUUID(workspaceID), - AgentID: parseUUID(req.AgentID), + WorkspaceID: workspaceUUID, + AgentID: agentID, CreatorID: parseUUID(userID), Title: req.Title, }) @@ -126,6 +134,30 @@ func (h *Handler) ListChatSessions(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +func (h *Handler) loadChatSessionForUser(w http.ResponseWriter, r *http.Request, userID, workspaceID, sessionID string) (db.ChatSession, bool) { + sessionUUID, ok := parseUUIDOrBadRequest(w, sessionID, "chat session id") + if !ok { + return db.ChatSession{}, false + } + workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return db.ChatSession{}, false + } + session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{ + ID: sessionUUID, + WorkspaceID: workspaceUUID, + }) + if err != nil { + writeError(w, http.StatusNotFound, "chat session not found") + return db.ChatSession{}, false + } + if uuidToString(session.CreatorID) != userID { + writeError(w, http.StatusForbidden, "not your chat session") + return db.ChatSession{}, false + } + return session, true +} + func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok { @@ -134,16 +166,8 @@ func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) { workspaceID := ctxWorkspaceID(r.Context()) sessionID := chi.URLParam(r, "sessionId") - session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{ - ID: parseUUID(sessionID), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "chat session not found") - return - } - if uuidToString(session.CreatorID) != userID { - writeError(w, http.StatusForbidden, "not your chat session") + session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID) + if !ok { return } @@ -158,20 +182,12 @@ func (h *Handler) ArchiveChatSession(w http.ResponseWriter, r *http.Request) { workspaceID := ctxWorkspaceID(r.Context()) sessionID := chi.URLParam(r, "sessionId") - session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{ - ID: parseUUID(sessionID), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "chat session not found") - return - } - if uuidToString(session.CreatorID) != userID { - writeError(w, http.StatusForbidden, "not your chat session") + session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID) + if !ok { return } - if err := h.Queries.ArchiveChatSession(r.Context(), parseUUID(sessionID)); err != nil { + if err := h.Queries.ArchiveChatSession(r.Context(), session.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to archive chat session") return } @@ -211,22 +227,14 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) { } // Load chat session. - session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{ - ID: parseUUID(sessionID), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "chat session not found") + session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID) + if !ok { return } if session.Status != "active" { writeError(w, http.StatusBadRequest, "chat session is archived") return } - if uuidToString(session.CreatorID) != userID { - writeError(w, http.StatusForbidden, "not your chat session") - return - } // Create the user message first so the daemon can always find it. msg, err := h.Queries.CreateChatMessage(r.Context(), db.CreateChatMessageParams{ @@ -252,8 +260,9 @@ func (h *Handler) SendChatMessage(w http.ResponseWriter, r *http.Request) { } // Broadcast the user message. - h.publishChat(protocol.EventChatMessage, workspaceID, "member", userID, sessionID, protocol.ChatMessagePayload{ - ChatSessionID: sessionID, + resolvedSessionID := uuidToString(session.ID) + h.publishChat(protocol.EventChatMessage, workspaceID, "member", userID, resolvedSessionID, protocol.ChatMessagePayload{ + ChatSessionID: resolvedSessionID, MessageID: uuidToString(msg.ID), Role: "user", Content: req.Content, @@ -275,20 +284,12 @@ func (h *Handler) ListChatMessages(w http.ResponseWriter, r *http.Request) { workspaceID := ctxWorkspaceID(r.Context()) sessionID := chi.URLParam(r, "sessionId") - session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{ - ID: parseUUID(sessionID), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "chat session not found") - return - } - if uuidToString(session.CreatorID) != userID { - writeError(w, http.StatusForbidden, "not your chat session") + session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID) + if !ok { return } - messages, err := h.Queries.ListChatMessages(r.Context(), parseUUID(sessionID)) + messages, err := h.Queries.ListChatMessages(r.Context(), session.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list chat messages") return @@ -319,26 +320,19 @@ func (h *Handler) MarkChatSessionRead(w http.ResponseWriter, r *http.Request) { workspaceID := ctxWorkspaceID(r.Context()) sessionID := chi.URLParam(r, "sessionId") - session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{ - ID: parseUUID(sessionID), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "chat session not found") - return - } - if uuidToString(session.CreatorID) != userID { - writeError(w, http.StatusForbidden, "not your chat session") + session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID) + if !ok { return } - if err := h.Queries.MarkChatSessionRead(r.Context(), parseUUID(sessionID)); err != nil { + if err := h.Queries.MarkChatSessionRead(r.Context(), session.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to mark session read") return } - h.publishChat(protocol.EventChatSessionRead, workspaceID, "member", userID, sessionID, protocol.ChatSessionReadPayload{ - ChatSessionID: sessionID, + resolvedSessionID := uuidToString(session.ID) + h.publishChat(protocol.EventChatSessionRead, workspaceID, "member", userID, resolvedSessionID, protocol.ChatSessionReadPayload{ + ChatSessionID: resolvedSessionID, }) w.WriteHeader(http.StatusNoContent) @@ -396,20 +390,12 @@ func (h *Handler) GetPendingChatTask(w http.ResponseWriter, r *http.Request) { workspaceID := ctxWorkspaceID(r.Context()) sessionID := chi.URLParam(r, "sessionId") - session, err := h.Queries.GetChatSessionInWorkspace(r.Context(), db.GetChatSessionInWorkspaceParams{ - ID: parseUUID(sessionID), - WorkspaceID: parseUUID(workspaceID), - }) - if err != nil { - writeError(w, http.StatusNotFound, "chat session not found") - return - } - if uuidToString(session.CreatorID) != userID { - writeError(w, http.StatusForbidden, "not your chat session") + session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, sessionID) + if !ok { return } - task, err := h.Queries.GetPendingChatTask(r.Context(), parseUUID(sessionID)) + task, err := h.Queries.GetPendingChatTask(r.Context(), session.ID) if err != nil { // No in-flight task — return an empty object, not an error. writeJSON(w, http.StatusOK, PendingChatTaskResponse{}) @@ -435,8 +421,12 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) { } workspaceID := ctxWorkspaceID(r.Context()) taskID := chi.URLParam(r, "taskId") + taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task id") + if !ok { + return + } - task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskID)) + task, err := h.Queries.GetAgentTask(r.Context(), taskUUID) if err != nil { writeError(w, http.StatusNotFound, "task not found") return @@ -468,7 +458,7 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) { return } - cancelled, err := h.TaskService.CancelTask(r.Context(), parseUUID(taskID)) + cancelled, err := h.TaskService.CancelTask(r.Context(), taskUUID) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 312e4b60aa..22e57000b7 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -203,15 +203,25 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { var parentID pgtype.UUID var parentComment *db.Comment if req.ParentID != nil { - parentID = parseUUID(*req.ParentID) + var parsed pgtype.UUID + parsed, ok = parseUUIDOrBadRequest(w, *req.ParentID, "parent_id") + if !ok { + return + } + parentID = parsed parent, err := h.Queries.GetComment(r.Context(), parentID) - if err != nil || uuidToString(parent.IssueID) != issueID { + if err != nil || uuidToString(parent.IssueID) != uuidToString(issue.ID) { writeError(w, http.StatusBadRequest, "invalid parent comment") return } parentComment = &parent } + attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids") + if !ok { + return + } + // Determine author identity: agent (via X-Agent-ID header) or member. authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID)) @@ -227,12 +237,15 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { // 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 + taskUUID, parseErr := util.ParseUUID(taskIDHeader) + if parseErr == nil { + task, err := h.Queries.GetAgentTask(r.Context(), taskUUID) + 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 + } } } } @@ -263,8 +276,8 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { } // Link uploaded attachments to this comment. - if len(req.AttachmentIDs) > 0 { - h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, req.AttachmentIDs) + if len(attachmentIDs) > 0 { + h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, attachmentIDs) } // Fetch linked attachments so the response includes them. @@ -461,12 +474,20 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { if !ok { return } + commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id") + if !ok { + return + } // Load comment scoped to current workspace. workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } existing, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ - ID: parseUUID(commentId), - WorkspaceID: parseUUID(workspaceID), + ID: commentUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "comment not found") @@ -501,7 +522,7 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { // NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here. comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{ - ID: parseUUID(commentId), + ID: commentUUID, Content: req.Content, }) if err != nil { @@ -528,11 +549,20 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) { return } + commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id") + if !ok { + return + } + // Load comment scoped to current workspace. workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ - ID: parseUUID(commentId), - WorkspaceID: parseUUID(workspaceID), + ID: commentUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "comment not found") @@ -553,17 +583,17 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) { } // Collect attachment URLs before CASCADE delete removes them. - attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), parseUUID(commentId)) + attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), comment.ID) // Cancel any active tasks triggered by this comment so the agent does not // run with the now-deleted content already embedded in its prompt. Must // run before DeleteComment because the FK ON DELETE SET NULL would // otherwise nullify trigger_comment_id and orphan those tasks in queued. - if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), parseUUID(commentId)); err != nil { + if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), comment.ID); err != nil { slog.Warn("cancel tasks for deleted trigger comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...) } - if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil { + if err := h.Queries.DeleteComment(r.Context(), comment.ID); err != nil { slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...) writeError(w, http.StatusInternalServerError, "failed to delete comment") return @@ -572,7 +602,7 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) { h.deleteS3Objects(r.Context(), attachmentURLs) slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...) h.publish(protocol.EventCommentDeleted, workspaceID, actorType, actorID, map[string]any{ - "comment_id": commentId, + "comment_id": uuidToString(comment.ID), "issue_id": uuidToString(comment.IssueID), }) w.WriteHeader(http.StatusNoContent) diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index d525890138..b92f121b34 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -52,7 +52,11 @@ func (h *Handler) requireDaemonWorkspaceAccess(w http.ResponseWriter, r *http.Re // requireDaemonRuntimeAccess looks up a runtime and verifies the caller owns its workspace. func (h *Handler) requireDaemonRuntimeAccess(w http.ResponseWriter, r *http.Request, runtimeID string) (db.AgentRuntime, bool) { - rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID)) + runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id") + if !ok { + return db.AgentRuntime{}, false + } + rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID) if err != nil { writeError(w, http.StatusNotFound, "runtime not found") return db.AgentRuntime{}, false @@ -65,7 +69,11 @@ func (h *Handler) requireDaemonRuntimeAccess(w http.ResponseWriter, r *http.Requ // requireDaemonTaskAccess looks up a task and verifies the caller owns its workspace. func (h *Handler) requireDaemonTaskAccess(w http.ResponseWriter, r *http.Request, taskID string) (db.AgentTaskQueue, bool) { - task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskID)) + taskUUID, ok := parseUUIDOrBadRequest(w, taskID, "task_id") + if !ok { + return db.AgentTaskQueue{}, false + } + task, err := h.Queries.GetAgentTask(r.Context(), taskUUID) if err != nil { writeError(w, http.StatusNotFound, "task not found") return db.AgentTaskQueue{}, false @@ -210,6 +218,11 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "at least one runtime is required") return } + wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id") + if !ok { + return + } + req.WorkspaceID = uuidToString(wsUUID) // Verify workspace access and resolve owner. // Daemon tokens (mdt_) prove workspace access directly; OwnerID will be zero @@ -230,7 +243,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { ownerID = member.UserID } - ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID)) + ws, err := h.Queries.GetWorkspace(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusNotFound, "workspace not found") return @@ -266,7 +279,7 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { }) row, err := h.Queries.UpsertAgentRuntime(r.Context(), db.UpsertAgentRuntimeParams{ - WorkspaceID: parseUUID(req.WorkspaceID), + WorkspaceID: wsUUID, DaemonID: strToText(req.DaemonID), Name: name, RuntimeMode: "local", @@ -444,13 +457,17 @@ func (h *Handler) DaemonDeregister(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "runtime_ids is required") return } + runtimeUUIDs, ok := parseUUIDSliceOrBadRequest(w, req.RuntimeIDs, "runtime_ids") + if !ok { + return + } // Track affected workspaces for WS notifications. affectedWorkspaces := make(map[string]bool) - for _, rid := range req.RuntimeIDs { + for i, rid := range req.RuntimeIDs { // Look up the runtime and verify ownership. - rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(rid)) + rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUIDs[i]) if err != nil { slog.Warn("deregister: runtime not found", "runtime_id", rid, "error", err) continue @@ -462,7 +479,7 @@ func (h *Handler) DaemonDeregister(w http.ResponseWriter, r *http.Request) { continue } - if err := h.Queries.SetAgentRuntimeOffline(r.Context(), parseUUID(rid)); err != nil { + if err := h.Queries.SetAgentRuntimeOffline(r.Context(), rt.ID); err != nil { slog.Warn("deregister: failed to set offline", "runtime_id", rid, "error", err) continue } @@ -524,13 +541,14 @@ func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) { runtimeID = req.RuntimeID // Verify the caller owns this runtime's workspace. - if _, ok := h.requireDaemonRuntimeAccess(w, r, req.RuntimeID); !ok { + rt, ok := h.requireDaemonRuntimeAccess(w, r, req.RuntimeID) + if !ok { return } authMs = time.Since(start).Milliseconds() updateStart := time.Now() - _, err := h.Queries.UpdateAgentRuntimeHeartbeat(r.Context(), parseUUID(req.RuntimeID)) + _, err := h.Queries.UpdateAgentRuntimeHeartbeat(r.Context(), rt.ID) updateMs = time.Since(updateStart).Milliseconds() if err != nil { outcome = "error_update" @@ -1449,7 +1467,11 @@ func (h *Handler) GetIssueUsage(w http.ResponseWriter, r *http.Request) { // read issue metadata from workspace B via UUID enumeration. func (h *Handler) GetIssueGCCheck(w http.ResponseWriter, r *http.Request) { issueID := chi.URLParam(r, "issueId") - issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID)) + issueUUID, ok := parseUUIDOrBadRequest(w, issueID, "issue_id") + if !ok { + return + } + issue, err := h.Queries.GetIssue(r.Context(), issueUUID) if err != nil { writeError(w, http.StatusNotFound, "issue not found") return diff --git a/server/internal/handler/feedback.go b/server/internal/handler/feedback.go index f427abcc76..45c8b9dd95 100644 --- a/server/internal/handler/feedback.go +++ b/server/internal/handler/feedback.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" + "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/analytics" "github.com/multica-ai/multica/server/internal/logger" "github.com/multica-ai/multica/server/internal/middleware" @@ -93,9 +94,13 @@ func (h *Handler) CreateFeedback(w http.ResponseWriter, r *http.Request) { metaBytes = []byte("{}") } - var workspaceID = parseUUID("") + var workspaceID pgtype.UUID if req.WorkspaceID != nil && *req.WorkspaceID != "" { - workspaceID = parseUUID(*req.WorkspaceID) + ws, ok := parseUUIDOrBadRequest(w, *req.WorkspaceID, "workspace_id") + if !ok { + return + } + workspaceID = ws } fb, err := h.Queries.CreateFeedback(r.Context(), db.CreateFeedbackParams{ diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index 5267b544ab..06c4ad2f71 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -188,8 +188,12 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { } if issueID := r.FormValue("issue_id"); issueID != "" { + issueUUID, ok := parseUUIDOrBadRequest(w, issueID, "issue_id") + if !ok { + return + } issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ - ID: parseUUID(issueID), + ID: issueUUID, WorkspaceID: parseUUID(workspaceID), }) if err != nil { @@ -199,7 +203,11 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { params.IssueID = issue.ID } if commentID := r.FormValue("comment_id"); commentID != "" { - comment, err := h.Queries.GetComment(r.Context(), parseUUID(commentID)) + commentUUID, ok := parseUUIDOrBadRequest(w, commentID, "comment_id") + if !ok { + return + } + comment, err := h.Queries.GetComment(r.Context(), commentUUID) if err != nil || uuidToString(comment.WorkspaceID) != workspaceID { writeError(w, http.StatusForbidden, "invalid comment_id") return @@ -285,9 +293,18 @@ func (h *Handler) GetAttachmentByID(w http.ResponseWriter, r *http.Request) { return } + attUUID, ok := parseUUIDOrBadRequest(w, attachmentID, "attachment id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{ - ID: parseUUID(attachmentID), - WorkspaceID: parseUUID(workspaceID), + ID: attUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "attachment not found") @@ -314,9 +331,18 @@ func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) { return } + attUUID, ok := parseUUIDOrBadRequest(w, attachmentID, "attachment id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{ - ID: parseUUID(attachmentID), - WorkspaceID: parseUUID(workspaceID), + ID: attUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "attachment not found") @@ -353,15 +379,11 @@ func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) { // linkAttachmentsByIssueIDs links the given attachment IDs to an issue. // Only updates attachments that have no issue_id yet. -func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, workspaceID pgtype.UUID, ids []string) { - uuids := make([]pgtype.UUID, len(ids)) - for i, id := range ids { - uuids[i] = parseUUID(id) - } +func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, workspaceID pgtype.UUID, ids []pgtype.UUID) { if err := h.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{ IssueID: issueID, WorkspaceID: workspaceID, - Column3: uuids, + Column3: ids, }); err != nil { slog.Error("failed to link attachments to issue", "error", err) } @@ -369,15 +391,11 @@ func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, worksp // linkAttachmentsByIDs links the given attachment IDs to a comment. // Only updates attachments that belong to the same issue and have no comment_id yet. -func (h *Handler) linkAttachmentsByIDs(ctx context.Context, commentID, issueID pgtype.UUID, ids []string) { - uuids := make([]pgtype.UUID, len(ids)) - for i, id := range ids { - uuids[i] = parseUUID(id) - } +func (h *Handler) linkAttachmentsByIDs(ctx context.Context, commentID, issueID pgtype.UUID, ids []pgtype.UUID) { if err := h.Queries.LinkAttachmentsToComment(ctx, db.LinkAttachmentsToCommentParams{ CommentID: commentID, IssueID: issueID, - Column3: uuids, + Column3: ids, }); err != nil { slog.Error("failed to link attachments to comment", "error", err) } diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index a687850b8e..3b89a4c95c 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -108,8 +108,19 @@ func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } -// Thin wrappers around util functions (preserve existing handler code unchanged). -func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) } +// Thin wrappers around util functions. +// +// parseUUID is intentionally the panicking variant: any handler call site +// reachable here is expected to feed a UUID that is either (a) a sqlc round-trip +// of a DB-sourced value, or (b) a raw request input that has already been +// validated upstream. A panic here means an unguarded user-input string slipped +// in — that is a real bug we want surfaced loudly (chi's middleware.Recoverer +// converts it to a 500) instead of silently corrupting data via a zero UUID. +// +// For unvalidated user input at request boundaries, use parseUUIDOrBadRequest +// (writes 400) — never feed raw chi.URLParam / request-body strings into +// parseUUID directly when the call writes to the database. +func parseUUID(s string) pgtype.UUID { return util.MustParseUUID(s) } func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) } func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) } func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) } @@ -118,6 +129,35 @@ func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToStr func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) } func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) } +// parseUUIDOrBadRequest validates a UUID string sourced from user input +// (URL params, request body, headers). On invalid input it writes a 400 +// response and returns ok=false; callers must return immediately. +// +// Use this anywhere a malformed UUID would otherwise reach a write query +// (DELETE / UPDATE) — the silent zero-UUID behavior of the old ParseUUID +// caused real silent-data-loss bugs (#1661). +func parseUUIDOrBadRequest(w http.ResponseWriter, s, fieldName string) (pgtype.UUID, bool) { + u, err := util.ParseUUID(s) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid "+fieldName) + return pgtype.UUID{}, false + } + return u, true +} + +func parseUUIDSliceOrBadRequest(w http.ResponseWriter, ids []string, fieldName string) ([]pgtype.UUID, bool) { + uuids := make([]pgtype.UUID, len(ids)) + for i, id := range ids { + u, err := util.ParseUUID(id) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid "+fieldName) + return nil, false + } + uuids[i] = u + } + return uuids, true +} + // publish sends a domain event through the event bus. func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) { h.Bus.Publish(events.Event{ @@ -179,8 +219,13 @@ func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (act return "member", userID } + agentUUID, err := util.ParseUUID(agentID) + if err != nil { + slog.Debug("resolveActor: X-Agent-ID is not a valid UUID, falling back to member", "agent_id", agentID) + return "member", userID + } // Validate the agent exists in the target workspace. - agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID)) + agent, err := h.Queries.GetAgent(r.Context(), agentUUID) if err != nil || uuidToString(agent.WorkspaceID) != workspaceID { slog.Debug("resolveActor: X-Agent-ID rejected, agent not found or workspace mismatch", "agent_id", agentID, "workspace_id", workspaceID) return "member", userID @@ -188,7 +233,12 @@ func (h *Handler) resolveActor(r *http.Request, userID, workspaceID string) (act // When X-Task-ID is provided, cross-check that the task belongs to this agent. if taskID := r.Header.Get("X-Task-ID"); taskID != "" { - task, err := h.Queries.GetAgentTask(r.Context(), parseUUID(taskID)) + taskUUID, err := util.ParseUUID(taskID) + if err != nil { + slog.Debug("resolveActor: X-Task-ID is not a valid UUID, falling back to member", "task_id", taskID) + return "member", userID + } + task, err := h.Queries.GetAgentTask(r.Context(), taskUUID) if err != nil || uuidToString(task.AgentID) != agentID { slog.Debug("resolveActor: X-Task-ID rejected, task not found or agent mismatch", "agent_id", agentID, "task_id", taskID) return "member", userID @@ -265,9 +315,17 @@ func countOwners(members []db.Member) int { } func (h *Handler) getWorkspaceMember(ctx context.Context, userID, workspaceID string) (db.Member, error) { + userUUID, err := util.ParseUUID(userID) + if err != nil { + return db.Member{}, err + } + wsUUID, err := util.ParseUUID(workspaceID) + if err != nil { + return db.Member{}, err + } return h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{ - UserID: parseUUID(userID), - WorkspaceID: parseUUID(workspaceID), + UserID: userUUID, + WorkspaceID: wsUUID, }) } @@ -311,9 +369,17 @@ func (h *Handler) isWorkspaceEntity(ctx context.Context, userType, userID, works _, err := h.getWorkspaceMember(ctx, userID, workspaceID) return err == nil case "agent": - _, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{ - ID: parseUUID(userID), - WorkspaceID: parseUUID(workspaceID), + userUUID, err := util.ParseUUID(userID) + if err != nil { + return false + } + wsUUID, err := util.ParseUUID(workspaceID) + if err != nil { + return false + } + _, err = h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{ + ID: userUUID, + WorkspaceID: wsUUID, }) return err == nil default: @@ -332,14 +398,28 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue return db.Issue{}, false } - // Try identifier format first (e.g., "JIA-42"). + // Try identifier format first (e.g., "JIA-42"). resolveIssueByIdentifier + // silently returns false for non-identifier strings, falling through to + // the UUID path below. if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, workspaceID); ok { return issue, true } + issueUUID, err := util.ParseUUID(issueID) + if err != nil { + // Not a valid UUID and didn't match identifier format → 404 (consistent + // with previous silent-zero behavior, which would also have produced 404). + writeError(w, http.StatusNotFound, "issue not found") + return db.Issue{}, false + } + wsUUID, err := util.ParseUUID(workspaceID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid workspace_id") + return db.Issue{}, false + } issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ - ID: parseUUID(issueID), - WorkspaceID: parseUUID(workspaceID), + ID: issueUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "issue not found") @@ -357,8 +437,12 @@ func (h *Handler) resolveIssueByIdentifier(ctx context.Context, id, workspaceID if workspaceID == "" { return db.Issue{}, false } + wsUUID, err := util.ParseUUID(workspaceID) + if err != nil { + return db.Issue{}, false + } issue, err := h.Queries.GetIssueByNumber(ctx, db.GetIssueByNumberParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, Number: parts.number, }) if err != nil { @@ -422,9 +506,18 @@ func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agent return db.Agent{}, false } + agentUUID, ok := parseUUIDOrBadRequest(w, agentID, "agent id") + if !ok { + return db.Agent{}, false + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return db.Agent{}, false + } + agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{ - ID: parseUUID(agentID), - WorkspaceID: parseUUID(workspaceID), + ID: agentUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "agent not found") @@ -445,9 +538,18 @@ func (h *Handler) loadInboxItemForUser(w http.ResponseWriter, r *http.Request, i return db.InboxItem{}, false } + itemUUID, ok := parseUUIDOrBadRequest(w, itemID, "inbox item id") + if !ok { + return db.InboxItem{}, false + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return db.InboxItem{}, false + } + item, err := h.Queries.GetInboxItemInWorkspace(r.Context(), db.GetInboxItemInWorkspaceParams{ - ID: parseUUID(itemID), - WorkspaceID: parseUUID(workspaceID), + ID: itemUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "inbox item not found") diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 0c6205ab55..04b70e3802 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -10,6 +10,7 @@ import ( "os" "strings" "testing" + "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -18,6 +19,7 @@ import ( "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/protocol" ) var testHandler *Handler @@ -330,6 +332,92 @@ func TestIssueCRUD(t *testing.T) { } } +// TestDeleteIssueByIdentifier guards against #1661 — DELETE /api/issues/{id} +// must actually delete the row when the path segment is a human-readable +// identifier ("HAN-42") rather than a UUID. Before the PR #1680 + MUL-1410 +// refactor, parseUUID(rawString) silently produced a zero UUID, the SQL +// DELETE matched nothing, and the handler still returned 204. +// +// Also asserts the issue:deleted WS event payload carries the resolved UUID, +// not the raw identifier — frontend caches key by UUID and would otherwise +// leave stale entries on other clients after an identifier-path delete. +func TestDeleteIssueByIdentifier(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Issue to delete by identifier", + "status": "todo", + "priority": "medium", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var created IssueResponse + json.NewDecoder(w.Body).Decode(&created) + if created.Identifier == "" { + t.Fatalf("CreateIssue: expected identifier to be populated, got empty") + } + + // Capture the issue:deleted event payload via the bus. + gotPayload := make(chan map[string]any, 1) + testHandler.Bus.Subscribe(protocol.EventIssueDeleted, func(e events.Event) { + if payload, ok := e.Payload.(map[string]any); ok { + select { + case gotPayload <- payload: + default: + } + } + }) + + // Delete using the human-readable identifier (e.g. "HAN-1") rather than the UUID. + w = httptest.NewRecorder() + req = newRequest("DELETE", "/api/issues/"+created.Identifier, nil) + req = withURLParam(req, "id", created.Identifier) + testHandler.DeleteIssue(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("DeleteIssue by identifier: expected 204, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the row is actually gone — the silent-data-loss bug would have + // returned 204 here too, but the row would still exist. + var count int + if err := testPool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM issue WHERE id = $1`, created.ID, + ).Scan(&count); err != nil { + t.Fatalf("count query: %v", err) + } + if count != 0 { + t.Fatalf("DeleteIssue by identifier returned 204 but row still exists (count=%d) — silent-data-loss regression", count) + } + + // Event payload must carry the resolved UUID, not the identifier string. + select { + case payload := <-gotPayload: + issueID, _ := payload["issue_id"].(string) + if issueID != created.ID { + t.Fatalf("issue:deleted event payload issue_id = %q; want resolved UUID %q (must not leak identifier %q)", issueID, created.ID, created.Identifier) + } + case <-time.After(2 * time.Second): + t.Fatal("did not receive issue:deleted event within timeout") + } +} + +// TestDeleteIssueRejectsInvalidUUID verifies that a path segment that is +// neither a valid UUID nor a valid identifier returns 404 (not 204) — the +// handler must never silently succeed on malformed input. +func TestDeleteIssueRejectsInvalidUUID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("DELETE", "/api/issues/not-a-uuid-or-identifier", nil) + req = withURLParam(req, "id", "not-a-uuid-or-identifier") + testHandler.DeleteIssue(w, req) + if w.Code == http.StatusNoContent { + t.Fatalf("DeleteIssue with invalid id: must not return 204; got %d", w.Code) + } + if w.Code != http.StatusNotFound { + t.Fatalf("DeleteIssue with invalid id: expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + // TestCreateIssueDefaultStatusIsTodo verifies that issues created without an // explicit status default to "todo" so the daemon picks them up immediately. // Before this fix the default was "backlog", which daemons ignore. @@ -641,6 +729,31 @@ func TestCreateIssueRejectsMalformedAssigneeID(t *testing.T) { } } +func TestCreateIssueRejectsMalformedAttachmentIDBeforeWrite(t *testing.T) { + var before int + if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&before); err != nil { + t.Fatalf("count issues before: %v", err) + } + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Malformed attachment issue", + "attachment_ids": []string{"not-a-uuid"}, + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("CreateIssue: expected 400 for malformed attachment_ids, got %d: %s", w.Code, w.Body.String()) + } + + var after int + if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&after); err != nil { + t.Fatalf("count issues after: %v", err) + } + if after != before { + t.Fatalf("CreateIssue: malformed attachment_ids should not create issue, count before=%d after=%d", before, after) + } +} + // TestUpdateIssueRejectsMalformedAssigneeID is the equivalent for the update // path, where the same parseUUID-shaped gap existed on a previously-unassigned // issue. @@ -789,6 +902,283 @@ func TestCommentCRUD(t *testing.T) { testHandler.DeleteIssue(w, req) } +func TestCreateCommentRejectsMalformedParentID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Comment malformed parent issue", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var issue IssueResponse + json.NewDecoder(w.Body).Decode(&issue) + + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/"+issue.ID+"/comments", map[string]any{ + "content": "bad parent", + "parent_id": "not-a-uuid", + }) + req = withURLParam(req, "id", issue.ID) + testHandler.CreateComment(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("CreateComment: expected 400 for malformed parent_id, got %d: %s", w.Code, w.Body.String()) + } + + w = httptest.NewRecorder() + req = newRequest("DELETE", "/api/issues/"+issue.ID, nil) + req = withURLParam(req, "id", issue.ID) + testHandler.DeleteIssue(w, req) +} + +func TestGetChatSessionRejectsMalformedSessionID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("GET", "/api/chat/sessions/not-a-uuid", nil) + req = withURLParam(req, "sessionId", "not-a-uuid") + testHandler.GetChatSession(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("GetChatSession: expected 400 for malformed sessionId, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateAutopilotRejectsMalformedAssigneeID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/autopilots", map[string]any{ + "title": "Malformed assignee autopilot", + "assignee_id": "not-a-uuid", + "execution_mode": "run_only", + }) + testHandler.CreateAutopilot(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("CreateAutopilot: expected 400 for malformed assignee_id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateAutopilotRejectsMalformedID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("PUT", "/api/autopilots/not-a-uuid", map[string]any{ + "title": "Malformed autopilot id", + }) + req = withURLParam(req, "id", "not-a-uuid") + testHandler.UpdateAutopilot(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("UpdateAutopilot: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateAgentRejectsMalformedAgentID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("PUT", "/api/agents/not-a-uuid", map[string]any{ + "name": "Malformed agent id", + }) + req = withURLParam(req, "id", "not-a-uuid") + testHandler.UpdateAgent(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("UpdateAgent: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateAgentRejectsMalformedRuntimeID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/agents", map[string]any{ + "name": "Malformed runtime agent", + "runtime_id": "not-a-uuid", + }) + testHandler.CreateAgent(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("CreateAgent: expected 400 for malformed runtime_id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateAgentRejectsMalformedRuntimeID(t *testing.T) { + agentID := createHandlerTestAgent(t, "Handler Malformed Runtime Update", nil) + + w := httptest.NewRecorder() + req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{ + "runtime_id": "not-a-uuid", + }) + req = withURLParam(req, "id", agentID) + testHandler.UpdateAgent(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("UpdateAgent: expected 400 for malformed runtime_id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreatePinRejectsMalformedItemID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/pins", map[string]any{ + "item_type": "issue", + "item_id": "not-a-uuid", + }) + testHandler.CreatePin(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("CreatePin: expected 400 for malformed item_id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateWorkspaceRejectsMalformedID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("PUT", "/api/workspaces/not-a-uuid", map[string]any{ + "name": "Malformed workspace id", + }) + req = withURLParam(req, "id", "not-a-uuid") + testHandler.UpdateWorkspace(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("UpdateWorkspace: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateMemberRejectsMalformedMemberID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("PATCH", "/api/workspaces/"+testWorkspaceID+"/members/not-a-uuid", map[string]any{ + "role": "member", + }) + req = withURLParams(req, "id", testWorkspaceID, "memberId", "not-a-uuid") + testHandler.UpdateMember(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("UpdateMember: expected 400 for malformed memberId, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRevokeInvitationRejectsMalformedInvitationID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/invitations/not-a-uuid", nil) + req = withURLParams(req, "id", testWorkspaceID, "invitationId", "not-a-uuid") + testHandler.RevokeInvitation(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("RevokeInvitation: expected 400 for malformed invitationId, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetMyInvitationRejectsMalformedID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("GET", "/api/invitations/not-a-uuid", nil) + req = withURLParam(req, "id", "not-a-uuid") + testHandler.GetMyInvitation(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("GetMyInvitation: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAddReactionRejectsMalformedCommentID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/comments/not-a-uuid/reactions", map[string]any{ + "emoji": "thumbs_up", + }) + req = withURLParam(req, "commentId", "not-a-uuid") + testHandler.AddReaction(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("AddReaction: expected 400 for malformed commentId, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateCommentRejectsMalformedCommentID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("PUT", "/api/comments/not-a-uuid", map[string]any{ + "content": "updated", + }) + req = withURLParam(req, "commentId", "not-a-uuid") + testHandler.UpdateComment(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("UpdateComment: expected 400 for malformed commentId, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestMarkInboxReadRejectsMalformedItemID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/inbox/not-a-uuid/read", nil) + req = withURLParam(req, "id", "not-a-uuid") + testHandler.MarkInboxRead(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("MarkInboxRead: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRevokePersonalAccessTokenRejectsMalformedID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("DELETE", "/api/tokens/not-a-uuid", nil) + req = withURLParam(req, "id", "not-a-uuid") + testHandler.RevokePersonalAccessToken(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("RevokePersonalAccessToken: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRequestBodyUUIDFieldsRejectMalformed(t *testing.T) { + tests := []struct { + name string + req *http.Request + handle func(http.ResponseWriter, *http.Request) + }{ + { + name: "daemon register workspace_id", + req: newRequest("POST", "/api/daemon/register", map[string]any{ + "workspace_id": "not-a-uuid", + "daemon_id": "daemon-malformed-workspace", + "runtimes": []map[string]any{ + {"name": "codex", "type": "codex", "status": "online"}, + }, + }), + handle: testHandler.DaemonRegister, + }, + { + name: "import starter content workspace_id", + req: newRequest("POST", "/api/onboarding/starter-content/import", map[string]any{ + "workspace_id": "not-a-uuid", + "project": map[string]any{ + "title": "Getting Started", + }, + }), + handle: testHandler.ImportStarterContent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + tt.handle(w, tt.req) + if w.Code != http.StatusBadRequest { + t.Fatalf("%s: expected 400 for malformed body UUID, got %d: %s", tt.name, w.Code, w.Body.String()) + } + }) + } +} + +func TestDaemonDeregisterRejectsMalformedRuntimeID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/daemon/deregister", map[string]any{ + "runtime_ids": []string{"not-a-uuid"}, + }) + testHandler.DaemonDeregister(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("DaemonDeregister: expected 400 for malformed runtime_ids, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetIssueGCCheckRejectsMalformedIssueID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("GET", "/api/daemon/issues/not-a-uuid/gc-check", nil) + req = withURLParam(req, "issueId", "not-a-uuid") + testHandler.GetIssueGCCheck(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("GetIssueGCCheck: expected 400 for malformed issueId, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSetAgentSkillsRejectsMalformedSkillID(t *testing.T) { + agentID := createHandlerTestAgent(t, "Handler Malformed Skill Assignment", nil) + + w := httptest.NewRecorder() + req := newRequest("PUT", "/api/agents/"+agentID+"/skills", map[string]any{ + "skill_ids": []string{"not-a-uuid"}, + }) + req = withURLParam(req, "id", agentID) + testHandler.SetAgentSkills(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("SetAgentSkills: expected 400 for malformed skill_ids, got %d: %s", w.Code, w.Body.String()) + } +} + func TestAgentCRUD(t *testing.T) { // List agents w := httptest.NewRecorder() @@ -1038,7 +1428,7 @@ func TestSendCodeDbError(t *testing.T) { // We can't easily mock the DB here without changing architecture, // but we can simulate a DB error by closing the pool temporarily or // using a cancelled context if the query respects it. - + // Create a handler with a "broken" queries object is hard because it's a struct. // Instead, let's use a context that is already cancelled. ctx, cancel := context.WithCancel(context.Background()) @@ -1053,13 +1443,13 @@ func TestSendCodeDbError(t *testing.T) { req = req.WithContext(ctx) testHandler.SendCode(w, req) - + // If the DB query respects the cancelled context, it should return an error. // pgx usually returns context.Canceled which is not what isNotFound checks for. if w.Code != http.StatusInternalServerError { t.Fatalf("SendCode (db error): expected 500, got %d: %s", w.Code, w.Body.String()) } - + var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) if resp["error"] != "failed to lookup user" { diff --git a/server/internal/handler/inbox.go b/server/internal/handler/inbox.go index 1888ca940c..554202372f 100644 --- a/server/internal/handler/inbox.go +++ b/server/internal/handler/inbox.go @@ -91,9 +91,13 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) { return } workspaceID := ctxWorkspaceID(r.Context()) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } items, err := h.Queries.ListInboxItems(r.Context(), db.ListInboxItemsParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, RecipientType: "member", RecipientID: parseUUID(userID), }) @@ -112,10 +116,11 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) { func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - if _, ok := h.loadInboxItemForUser(w, r, id); !ok { + prev, ok := h.loadInboxItemForUser(w, r, id) + if !ok { return } - item, err := h.Queries.MarkInboxRead(r.Context(), parseUUID(id)) + item, err := h.Queries.MarkInboxRead(r.Context(), prev.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to mark read") return @@ -134,10 +139,11 @@ func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) { func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - if _, ok := h.loadInboxItemForUser(w, r, id); !ok { + prev, ok := h.loadInboxItemForUser(w, r, id) + if !ok { return } - item, err := h.Queries.ArchiveInboxItem(r.Context(), parseUUID(id)) + item, err := h.Queries.ArchiveInboxItem(r.Context(), prev.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to archive") return @@ -171,9 +177,13 @@ func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) { return } workspaceID := ctxWorkspaceID(r.Context()) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } count, err := h.Queries.CountUnreadInbox(r.Context(), db.CountUnreadInboxParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, RecipientType: "member", RecipientID: parseUUID(userID), }) @@ -191,9 +201,13 @@ func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) { return } workspaceID := ctxWorkspaceID(r.Context()) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } count, err := h.Queries.MarkAllInboxRead(r.Context(), db.MarkAllInboxReadParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, RecipientID: parseUUID(userID), }) if err != nil { @@ -216,9 +230,13 @@ func (h *Handler) ArchiveAllInbox(w http.ResponseWriter, r *http.Request) { return } workspaceID := ctxWorkspaceID(r.Context()) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } count, err := h.Queries.ArchiveAllInbox(r.Context(), db.ArchiveAllInboxParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, RecipientID: parseUUID(userID), }) if err != nil { @@ -241,9 +259,13 @@ func (h *Handler) ArchiveAllReadInbox(w http.ResponseWriter, r *http.Request) { return } workspaceID := ctxWorkspaceID(r.Context()) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } count, err := h.Queries.ArchiveAllReadInbox(r.Context(), db.ArchiveAllReadInboxParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, RecipientID: parseUUID(userID), }) if err != nil { @@ -266,9 +288,13 @@ func (h *Handler) ArchiveCompletedInbox(w http.ResponseWriter, r *http.Request) return } workspaceID := ctxWorkspaceID(r.Context()) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } count, err := h.Queries.ArchiveCompletedInbox(r.Context(), db.ArchiveCompletedInboxParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, RecipientID: parseUUID(userID), }) if err != nil { diff --git a/server/internal/handler/invitation.go b/server/internal/handler/invitation.go index a524e2ef54..7c6bc789ed 100644 --- a/server/internal/handler/invitation.go +++ b/server/internal/handler/invitation.go @@ -87,7 +87,7 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { if err == nil { _, memberErr := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{ UserID: existingUser.ID, - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: requester.WorkspaceID, }) if memberErr == nil { writeError(w, http.StatusConflict, "user is already a member") @@ -97,7 +97,7 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { // Check if there is already a pending invitation. _, err = h.Queries.GetPendingInvitationByEmail(r.Context(), db.GetPendingInvitationByEmailParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: requester.WorkspaceID, InviteeEmail: email, }) if err == nil { @@ -112,7 +112,7 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { } inv, err := h.Queries.CreateInvitation(r.Context(), db.CreateInvitationParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: requester.WorkspaceID, InviterID: requester.UserID, InviteeEmail: email, InviteeUserID: inviteeUserID, @@ -136,15 +136,15 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { userID := requestUserID(r) eventPayload := map[string]any{"invitation": resp} var workspaceName string - if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(workspaceID)); err == nil { + if ws, err := h.Queries.GetWorkspace(r.Context(), requester.WorkspaceID); err == nil { workspaceName = ws.Name eventPayload["workspace_name"] = ws.Name } - h.publish(protocol.EventInvitationCreated, workspaceID, "member", userID, eventPayload) + h.publish(protocol.EventInvitationCreated, uuidToString(requester.WorkspaceID), "member", userID, eventPayload) h.Analytics.Capture(analytics.TeamInviteSent( uuidToString(requester.UserID), - workspaceID, + uuidToString(requester.WorkspaceID), email, "email", )) @@ -173,8 +173,12 @@ func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListWorkspaceInvitations(w http.ResponseWriter, r *http.Request) { workspaceID := workspaceIDFromURL(r, "id") + workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } - rows, err := h.Queries.ListPendingInvitationsByWorkspace(r.Context(), parseUUID(workspaceID)) + rows, err := h.Queries.ListPendingInvitationsByWorkspace(r.Context(), workspaceUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list invitations") return @@ -209,9 +213,17 @@ func (h *Handler) ListWorkspaceInvitations(w http.ResponseWriter, r *http.Reques func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) { workspaceID := workspaceIDFromURL(r, "id") invitationID := chi.URLParam(r, "invitationId") + workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id") + if !ok { + return + } - inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID)) - if err != nil || uuidToString(inv.WorkspaceID) != workspaceID || inv.Status != "pending" { + inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID) + if err != nil || uuidToString(inv.WorkspaceID) != uuidToString(workspaceUUID) || inv.Status != "pending" { writeError(w, http.StatusNotFound, "invitation not found") return } @@ -224,8 +236,8 @@ func (h *Handler) RevokeInvitation(w http.ResponseWriter, r *http.Request) { slog.Info("invitation revoked", "invitation_id", invitationID, "workspace_id", workspaceID) userID := requestUserID(r) - h.publish(protocol.EventInvitationRevoked, workspaceID, "member", userID, map[string]any{ - "invitation_id": invitationID, + h.publish(protocol.EventInvitationRevoked, uuidToString(workspaceUUID), "member", userID, map[string]any{ + "invitation_id": uuidToString(inv.ID), "invitee_email": inv.InviteeEmail, "invitee_user_id": uuidToPtr(inv.InviteeUserID), }) @@ -245,7 +257,11 @@ func (h *Handler) GetMyInvitation(w http.ResponseWriter, r *http.Request) { } invitationID := chi.URLParam(r, "id") - inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID)) + invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id") + if !ok { + return + } + inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID) if err != nil { writeError(w, http.StatusNotFound, "invitation not found") return @@ -336,7 +352,11 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) { } invitationID := chi.URLParam(r, "id") - inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID)) + invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id") + if !ok { + return + } + inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID) if err != nil { writeError(w, http.StatusNotFound, "invitation not found") return @@ -413,7 +433,7 @@ func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) { // Notify the workspace about the acceptance. h.publish(protocol.EventInvitationAccepted, wsID, "member", userID, map[string]any{ - "invitation_id": invitationID, + "invitation_id": uuidToString(accepted.ID), "member": memberResp, }) @@ -445,7 +465,11 @@ func (h *Handler) DeclineInvitation(w http.ResponseWriter, r *http.Request) { } invitationID := chi.URLParam(r, "id") - inv, err := h.Queries.GetInvitation(r.Context(), parseUUID(invitationID)) + invitationUUID, ok := parseUUIDOrBadRequest(w, invitationID, "invitation id") + if !ok { + return + } + inv, err := h.Queries.GetInvitation(r.Context(), invitationUUID) if err != nil { writeError(w, http.StatusNotFound, "invitation not found") return @@ -477,7 +501,7 @@ func (h *Handler) DeclineInvitation(w http.ResponseWriter, r *http.Request) { wsID := uuidToString(declined.WorkspaceID) h.publish(protocol.EventInvitationDeclined, wsID, "member", userID, map[string]any{ - "invitation_id": invitationID, + "invitation_id": uuidToString(declined.ID), "invitee_email": declined.InviteeEmail, }) diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 9fdb10f8f0..10eee7d73d 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -16,6 +16,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/logger" + "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -510,7 +511,10 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { includeClosed := r.URL.Query().Get("include_closed") == "true" - wsUUID := parseUUID(workspaceID) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } terms := splitSearchTerms(q) queryNum, hasNum := parseQueryNumber(q) @@ -597,32 +601,53 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { ctx := r.Context() workspaceID := h.resolveWorkspaceID(r) - wsUUID := parseUUID(workspaceID) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } - // Parse optional filter params + // Parse optional filter params. Malformed UUIDs in filters return 400 — + // silently coercing them to a zero UUID would mask a client bug and let + // the query return an empty result set (or worse, match a NULL row). var priorityFilter pgtype.Text if p := r.URL.Query().Get("priority"); p != "" { priorityFilter = pgtype.Text{String: p, Valid: true} } var assigneeFilter pgtype.UUID if a := r.URL.Query().Get("assignee_id"); a != "" { - assigneeFilter = parseUUID(a) + id, ok := parseUUIDOrBadRequest(w, a, "assignee_id") + if !ok { + return + } + assigneeFilter = id } var assigneeIdsFilter []pgtype.UUID if ids := r.URL.Query().Get("assignee_ids"); ids != "" { for _, raw := range strings.Split(ids, ",") { if s := strings.TrimSpace(raw); s != "" { - assigneeIdsFilter = append(assigneeIdsFilter, parseUUID(s)) + id, ok := parseUUIDOrBadRequest(w, s, "assignee_ids") + if !ok { + return + } + assigneeIdsFilter = append(assigneeIdsFilter, id) } } } var creatorFilter pgtype.UUID if c := r.URL.Query().Get("creator_id"); c != "" { - creatorFilter = parseUUID(c) + id, ok := parseUUIDOrBadRequest(w, c, "creator_id") + if !ok { + return + } + creatorFilter = id } var projectFilter pgtype.UUID if p := r.URL.Query().Get("project_id"); p != "" { - projectFilter = parseUUID(p) + id, ok := parseUUIDOrBadRequest(w, p, "project_id") + if !ok { + return + } + projectFilter = id } // open_only=true returns all non-done/cancelled issues (no limit). @@ -794,7 +819,10 @@ func (h *Handler) ListChildIssues(w http.ResponseWriter, r *http.Request) { func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) { wsID := h.resolveWorkspaceID(r) - wsUUID := parseUUID(wsID) + wsUUID, ok := parseUUIDOrBadRequest(w, wsID, "workspace_id") + if !ok { + return + } rows, err := h.Queries.ChildIssueProgress(r.Context(), wsUUID) if err != nil { @@ -846,6 +874,10 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { } workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } // Get creator from context (set by auth middleware) creatorID, ok := requireUserID(w, r) @@ -868,14 +900,11 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { assigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true} } if req.AssigneeID != nil { - assigneeID = parseUUID(*req.AssigneeID) - // parseUUID silently returns an invalid pgtype.UUID for malformed input. - // Reject explicitly so the validator below cannot mistake "type unset - // + id unparseable" for "no assignee" and accept the request. - if !assigneeID.Valid { - writeError(w, http.StatusBadRequest, "assignee_id is not a valid UUID") + id, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id") + if !ok { return } + assigneeID = id } if status, msg := h.validateAssigneePair(r.Context(), r, workspaceID, assigneeType, assigneeID); status != 0 { @@ -886,14 +915,22 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { var parentIssueID pgtype.UUID var projectID pgtype.UUID if req.ProjectID != nil { - projectID = parseUUID(*req.ProjectID) + id, ok := parseUUIDOrBadRequest(w, *req.ProjectID, "project_id") + if !ok { + return + } + projectID = id } if req.ParentIssueID != nil { - parentIssueID = parseUUID(*req.ParentIssueID) + id, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id") + if !ok { + return + } + parentIssueID = id // Validate parent exists in the same workspace. parent, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ ID: parentIssueID, - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, }) if err != nil || !parent.ID.Valid { writeError(w, http.StatusBadRequest, "parent issue not found in this workspace") @@ -904,6 +941,11 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { } } + attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids") + if !ok { + return + } + var dueDate pgtype.Timestamptz if req.DueDate != nil && *req.DueDate != "" { t, err := time.Parse(time.RFC3339, *req.DueDate) @@ -924,7 +966,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { defer tx.Rollback(r.Context()) qtx := h.Queries.WithTx(tx) - issueNumber, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(workspaceID)) + issueNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID) if err != nil { slog.Warn("increment issue counter failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...) writeError(w, http.StatusInternalServerError, "failed to create issue") @@ -935,7 +977,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { creatorType, actualCreatorID := h.resolveActor(r, creatorID, workspaceID) issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, Title: req.Title, Description: ptrToText(req.Description), Status: status, @@ -962,15 +1004,15 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { } // Link any pre-uploaded attachments to this issue. - if len(req.AttachmentIDs) > 0 { - h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, req.AttachmentIDs) + if len(attachmentIDs) > 0 { + h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, attachmentIDs) } prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) resp := issueToResponse(issue, prefix) // Fetch linked attachments so they appear in the response. - if len(req.AttachmentIDs) > 0 { + if len(attachmentIDs) > 0 { attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{ IssueID: issue.ID, WorkspaceID: issue.WorkspaceID, @@ -1071,11 +1113,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { } if _, ok := rawFields["assignee_id"]; ok { if req.AssigneeID != nil { - params.AssigneeID = parseUUID(*req.AssigneeID) - if !params.AssigneeID.Valid { - writeError(w, http.StatusBadRequest, "assignee_id is not a valid UUID") + id, ok := parseUUIDOrBadRequest(w, *req.AssigneeID, "assignee_id") + if !ok { return } + params.AssigneeID = id } else { params.AssigneeID = pgtype.UUID{Valid: false} // explicit null = unassign } @@ -1094,9 +1136,14 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { } if _, ok := rawFields["parent_issue_id"]; ok { if req.ParentIssueID != nil { - newParentID := parseUUID(*req.ParentIssueID) - // Cannot set self as parent. - if uuidToString(newParentID) == id { + newParentID, ok := parseUUIDOrBadRequest(w, *req.ParentIssueID, "parent_issue_id") + if !ok { + return + } + // Cannot set self as parent. Compare against prevIssue.ID (the + // resolved entity), not the raw URL string — `id` may be an + // identifier like "MUL-7". + if newParentID == prevIssue.ID { writeError(w, http.StatusBadRequest, "an issue cannot be its own parent") return } @@ -1115,7 +1162,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { if err != nil || !ancestor.ParentIssueID.Valid { break } - if uuidToString(ancestor.ParentIssueID) == id { + if ancestor.ParentIssueID == prevIssue.ID { writeError(w, http.StatusBadRequest, "circular parent relationship detected") return } @@ -1128,7 +1175,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { } if _, ok := rawFields["project_id"]; ok { if req.ProjectID != nil { - params.ProjectID = parseUUID(*req.ProjectID) + projectUUID, ok := parseUUIDOrBadRequest(w, *req.ProjectID, "project_id") + if !ok { + return + } + params.ProjectID = projectUUID } else { params.ProjectID = pgtype.UUID{Valid: false} } @@ -1235,11 +1286,15 @@ func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, wor if assigneeType.Valid != assigneeID.Valid { return http.StatusBadRequest, "assignee_type and assignee_id must be provided together" } + wsUUID, err := util.ParseUUID(workspaceID) + if err != nil { + return http.StatusBadRequest, "invalid workspace_id" + } switch assigneeType.String { case "member": if _, err := h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{ UserID: assigneeID, - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, }); err != nil { return http.StatusBadRequest, "assignee_id does not refer to a member of this workspace" } @@ -1247,7 +1302,7 @@ func (h *Handler) validateAssigneePair(ctx context.Context, r *http.Request, wor case "agent": agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{ ID: assigneeID, - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, }) if err != nil { return http.StatusBadRequest, "assignee_id does not refer to an agent of this workspace" @@ -1341,8 +1396,12 @@ func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) { h.deleteS3Objects(r.Context(), attachmentURLs) userID := requestUserID(r) actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID)) - h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue_id": id}) - slog.Info("issue deleted", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", uuidToString(issue.WorkspaceID))...) + // Always emit the resolved UUID — frontend caches key by UUID, so an + // identifier-style payload ("MUL-123") would leave stale entries on + // other clients after an identifier-path delete. + resolvedID := uuidToString(issue.ID) + h.publish(protocol.EventIssueDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"issue_id": resolvedID}) + slog.Info("issue deleted", append(logger.RequestAttrs(r), "issue_id", resolvedID, "workspace_id", uuidToString(issue.WorkspaceID))...) w.WriteHeader(http.StatusNoContent) } @@ -1387,11 +1446,19 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { } workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } updated := 0 for _, issueID := range req.IssueIDs { + issueUUID, err := util.ParseUUID(issueID) + if err != nil { + continue + } prevIssue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ - ID: parseUUID(issueID), - WorkspaceID: parseUUID(workspaceID), + ID: issueUUID, + WorkspaceID: wsUUID, }) if err != nil { continue @@ -1430,10 +1497,11 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { } if _, ok := rawUpdates["assignee_id"]; ok { if req.Updates.AssigneeID != nil { - params.AssigneeID = parseUUID(*req.Updates.AssigneeID) - if !params.AssigneeID.Valid { + assigneeUUID, err := util.ParseUUID(*req.Updates.AssigneeID) + if err != nil { continue } + params.AssigneeID = assigneeUUID } else { params.AssigneeID = pgtype.UUID{Valid: false} } @@ -1452,9 +1520,12 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { if _, ok := rawUpdates["parent_issue_id"]; ok { if req.Updates.ParentIssueID != nil { - newParentID := parseUUID(*req.Updates.ParentIssueID) + newParentID, err := util.ParseUUID(*req.Updates.ParentIssueID) + if err != nil { + continue + } // Cannot set self as parent. - if uuidToString(newParentID) == issueID { + if newParentID == prevIssue.ID { continue } // Validate parent exists in the same workspace. @@ -1472,7 +1543,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { if err != nil || !ancestor.ParentIssueID.Valid { break } - if uuidToString(ancestor.ParentIssueID) == issueID { + if ancestor.ParentIssueID == prevIssue.ID { cycleDetected = true break } @@ -1488,7 +1559,11 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { } if _, ok := rawUpdates["project_id"]; ok { if req.Updates.ProjectID != nil { - params.ProjectID = parseUUID(*req.Updates.ProjectID) + projectUUID, err := util.ParseUUID(*req.Updates.ProjectID) + if err != nil { + continue + } + params.ProjectID = projectUUID } else { params.ProjectID = pgtype.UUID{Valid: false} } @@ -1575,11 +1650,19 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) { } workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } deleted := 0 for _, issueID := range req.IssueIDs { + issueUUID, err := util.ParseUUID(issueID) + if err != nil { + continue + } issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ - ID: parseUUID(issueID), - WorkspaceID: parseUUID(workspaceID), + ID: issueUUID, + WorkspaceID: wsUUID, }) if err != nil { continue @@ -1598,8 +1681,9 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) { h.deleteS3Objects(r.Context(), attachmentURLs) + // Always emit the resolved UUID — frontend caches key by UUID. actorType, actorID := h.resolveActor(r, userID, workspaceID) - h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": issueID}) + h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": uuidToString(issue.ID)}) deleted++ } diff --git a/server/internal/handler/label.go b/server/internal/handler/label.go index f7945a93a9..adf7fa6b16 100644 --- a/server/internal/handler/label.go +++ b/server/internal/handler/label.go @@ -115,8 +115,16 @@ func (h *Handler) ListLabels(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetLabel(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) + idUUID, ok := parseUUIDOrBadRequest(w, id, "label id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } label, err := h.Queries.GetLabel(r.Context(), db.GetLabelParams{ - ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID), + ID: idUUID, WorkspaceID: wsUUID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -185,9 +193,17 @@ func (h *Handler) UpdateLabel(w http.ResponseWriter, r *http.Request) { return } + idUUID, ok := parseUUIDOrBadRequest(w, id, "label id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } params := db.UpdateLabelParams{ - ID: parseUUID(id), - WorkspaceID: parseUUID(workspaceID), + ID: idUUID, + WorkspaceID: wsUUID, } if req.Name != nil { name, err := validateLabelName(*req.Name) @@ -236,10 +252,18 @@ func (h *Handler) DeleteLabel(w http.ResponseWriter, r *http.Request) { if !ok { return } + idUUID, ok := parseUUIDOrBadRequest(w, id, "label id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } // DeleteLabel is :one RETURNING id — ErrNoRows means the label wasn't in // this workspace (404). Any other error is a real 500. if _, err := h.Queries.DeleteLabel(r.Context(), db.DeleteLabelParams{ - ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID), + ID: idUUID, WorkspaceID: wsUUID, }); err != nil { if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "label not found") @@ -249,7 +273,7 @@ func (h *Handler) DeleteLabel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to delete label") return } - h.publish(protocol.EventLabelDeleted, workspaceID, "member", userID, map[string]any{"label_id": id}) + h.publish(protocol.EventLabelDeleted, workspaceID, "member", userID, map[string]any{"label_id": uuidToString(idUUID)}) w.WriteHeader(http.StatusNoContent) } @@ -267,13 +291,13 @@ type AttachLabelRequest struct { // nil → clients refetch via query invalidation, and we skip broadcasting an // empty list that would incorrectly overwrite every subscriber's optimistic // state. -func (h *Handler) listLabelsForIssueSafe(r *http.Request, issueID, workspaceID string) ([]db.IssueLabel, bool) { +func (h *Handler) listLabelsForIssueSafe(r *http.Request, issueID, workspaceID pgtype.UUID) ([]db.IssueLabel, bool) { labels, err := h.Queries.ListLabelsByIssue(r.Context(), db.ListLabelsByIssueParams{ - IssueID: parseUUID(issueID), - WorkspaceID: parseUUID(workspaceID), + IssueID: issueID, + WorkspaceID: workspaceID, }) if err != nil { - slog.Warn("ListLabelsByIssue failed after mutation", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...) + slog.Warn("ListLabelsByIssue failed after mutation", append(logger.RequestAttrs(r), "error", err, "issue_id", uuidToString(issueID))...) return nil, false } return labels, true @@ -282,17 +306,15 @@ func (h *Handler) listLabelsForIssueSafe(r *http.Request, issueID, workspaceID s // ListLabelsForIssue returns the labels currently attached to an issue. func (h *Handler) ListLabelsForIssue(w http.ResponseWriter, r *http.Request) { issueID := chi.URLParam(r, "id") - workspaceID := h.resolveWorkspaceID(r) // Authorize via the issue — if it's not in this workspace, the caller // shouldn't see its labels. - issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID)) - if err != nil || uuidToString(issue.WorkspaceID) != workspaceID { - writeError(w, http.StatusNotFound, "issue not found") + issue, ok := h.loadIssueForUser(w, r, issueID) + if !ok { return } labels, err := h.Queries.ListLabelsByIssue(r.Context(), db.ListLabelsByIssueParams{ - IssueID: parseUUID(issueID), - WorkspaceID: parseUUID(workspaceID), + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, }) if err != nil { slog.Warn("ListLabelsForIssue failed", append(logger.RequestAttrs(r), "error", err)...) @@ -305,7 +327,6 @@ func (h *Handler) ListLabelsForIssue(w http.ResponseWriter, r *http.Request) { // AttachLabel attaches a label to an issue. func (h *Handler) AttachLabel(w http.ResponseWriter, r *http.Request) { issueID := chi.URLParam(r, "id") - workspaceID := h.resolveWorkspaceID(r) userID, ok := requireUserID(w, r) if !ok { return @@ -322,13 +343,16 @@ func (h *Handler) AttachLabel(w http.ResponseWriter, r *http.Request) { } // Both the issue and label must belong to this workspace. - issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID)) - if err != nil || uuidToString(issue.WorkspaceID) != workspaceID { - writeError(w, http.StatusNotFound, "issue not found") + issue, ok := h.loadIssueForUser(w, r, issueID) + if !ok { + return + } + labelID, ok := parseUUIDOrBadRequest(w, req.LabelID, "label_id") + if !ok { return } if _, err := h.Queries.GetLabel(r.Context(), db.GetLabelParams{ - ID: parseUUID(req.LabelID), WorkspaceID: parseUUID(workspaceID), + ID: labelID, WorkspaceID: issue.WorkspaceID, }); err != nil { if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "label not found") @@ -340,9 +364,9 @@ func (h *Handler) AttachLabel(w http.ResponseWriter, r *http.Request) { } if err := h.Queries.AttachLabelToIssue(r.Context(), db.AttachLabelToIssueParams{ - IssueID: parseUUID(issueID), - LabelID: parseUUID(req.LabelID), - WorkspaceID: parseUUID(workspaceID), + IssueID: issue.ID, + LabelID: labelID, + WorkspaceID: issue.WorkspaceID, }); err != nil { slog.Warn("AttachLabelToIssue failed", append(logger.RequestAttrs(r), "error", err)...) writeError(w, http.StatusInternalServerError, "failed to attach label") @@ -353,14 +377,14 @@ func (h *Handler) AttachLabel(w http.ResponseWriter, r *http.Request) { // committed — return success without a labels body (clients refetch via // query invalidation) and skip the broadcast so we don't overwrite every // subscriber's optimistic state with an incorrect empty list. - labels, ok2 := h.listLabelsForIssueSafe(r, issueID, workspaceID) + labels, ok2 := h.listLabelsForIssueSafe(r, issue.ID, issue.WorkspaceID) if !ok2 { writeJSON(w, http.StatusOK, map[string]any{}) return } resp := labelsToResponse(labels) - h.publish(protocol.EventIssueLabelsChanged, workspaceID, "member", userID, map[string]any{ - "issue_id": issueID, + h.publish(protocol.EventIssueLabelsChanged, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{ + "issue_id": uuidToString(issue.ID), "labels": resp, }) writeJSON(w, http.StatusOK, map[string]any{"labels": resp}) @@ -370,7 +394,6 @@ func (h *Handler) AttachLabel(w http.ResponseWriter, r *http.Request) { func (h *Handler) DetachLabel(w http.ResponseWriter, r *http.Request) { issueID := chi.URLParam(r, "id") labelID := chi.URLParam(r, "labelId") - workspaceID := h.resolveWorkspaceID(r) userID, ok := requireUserID(w, r) if !ok { return @@ -380,13 +403,16 @@ func (h *Handler) DetachLabel(w http.ResponseWriter, r *http.Request) { // (mirror of AttachLabel). Without this, a crafted request with a foreign // labelID would no-op and return 200 — "silent success" is worse than an // explicit 404. - issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID)) - if err != nil || uuidToString(issue.WorkspaceID) != workspaceID { - writeError(w, http.StatusNotFound, "issue not found") + issue, ok := h.loadIssueForUser(w, r, issueID) + if !ok { + return + } + labelUUID, ok := parseUUIDOrBadRequest(w, labelID, "label id") + if !ok { return } if _, err := h.Queries.GetLabel(r.Context(), db.GetLabelParams{ - ID: parseUUID(labelID), WorkspaceID: parseUUID(workspaceID), + ID: labelUUID, WorkspaceID: issue.WorkspaceID, }); err != nil { if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "label not found") @@ -398,23 +424,23 @@ func (h *Handler) DetachLabel(w http.ResponseWriter, r *http.Request) { } if err := h.Queries.DetachLabelFromIssue(r.Context(), db.DetachLabelFromIssueParams{ - IssueID: parseUUID(issueID), - LabelID: parseUUID(labelID), - WorkspaceID: parseUUID(workspaceID), + IssueID: issue.ID, + LabelID: labelUUID, + WorkspaceID: issue.WorkspaceID, }); err != nil { slog.Warn("DetachLabelFromIssue failed", append(logger.RequestAttrs(r), "error", err)...) writeError(w, http.StatusInternalServerError, "failed to detach label") return } - labels, ok2 := h.listLabelsForIssueSafe(r, issueID, workspaceID) + labels, ok2 := h.listLabelsForIssueSafe(r, issue.ID, issue.WorkspaceID) if !ok2 { writeJSON(w, http.StatusOK, map[string]any{}) return } resp := labelsToResponse(labels) - h.publish(protocol.EventIssueLabelsChanged, workspaceID, "member", userID, map[string]any{ - "issue_id": issueID, + h.publish(protocol.EventIssueLabelsChanged, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{ + "issue_id": uuidToString(issue.ID), "labels": resp, }) writeJSON(w, http.StatusOK, map[string]any{"labels": resp}) diff --git a/server/internal/handler/onboarding.go b/server/internal/handler/onboarding.go index 7a1c714b31..f77ef5e5c4 100644 --- a/server/internal/handler/onboarding.go +++ b/server/internal/handler/onboarding.go @@ -7,11 +7,11 @@ import ( "net/mail" "strings" - "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/analytics" "github.com/multica-ai/multica/server/internal/logger" + "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -356,16 +356,14 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "workspace_id is required") return } - // Reject malformed UUIDs up front. Without this, `parseUUID` below - // silently returns a zero-UUID and the membership check fails with - // a misleading 403 "not a member of this workspace" instead of the - // true 400. Defense-in-depth: even if the membership check is ever - // refactored, a garbage workspace_id never reaches CreateProject / + // Reject malformed UUIDs up front and reuse the parsed value for every + // write below so a garbage workspace_id never reaches CreateProject / // CreateIssue. - if _, err := uuid.Parse(req.WorkspaceID); err != nil { - writeError(w, http.StatusBadRequest, "workspace_id is invalid") + wsUUID, ok := parseUUIDOrBadRequest(w, req.WorkspaceID, "workspace_id") + if !ok { return } + req.WorkspaceID = uuidToString(wsUUID) if req.Project.Title == "" { writeError(w, http.StatusBadRequest, "project.title is required") return @@ -406,7 +404,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { // since members are looked up by `user_id`. if _, err := qtx.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{ UserID: parseUUID(userID), - WorkspaceID: parseUUID(req.WorkspaceID), + WorkspaceID: wsUUID, }); err != nil { writeError(w, http.StatusForbidden, "not a member of this workspace") return @@ -418,7 +416,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { // workspace. `ListAgents` orders by created_at ASC, so "agents[0]" // is deterministically the earliest-created agent. This replaces // the old client-supplied `welcome_issue.agent_id` trust chain. - agents, err := qtx.ListAgents(r.Context(), parseUUID(req.WorkspaceID)) + agents, err := qtx.ListAgents(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list agents") return @@ -435,7 +433,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { // --- Create project --- project, err := qtx.CreateProject(r.Context(), db.CreateProjectParams{ - WorkspaceID: parseUUID(req.WorkspaceID), + WorkspaceID: wsUUID, Title: req.Project.Title, Description: strOrNullText(req.Project.Description), Icon: strOrNullText(req.Project.Icon), @@ -452,7 +450,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { var welcomeIssueID *string var welcomeIssueForEvent *db.Issue if hasAgent && req.WelcomeIssueTemplate.Title != "" { - welcomeNumber, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(req.WorkspaceID)) + welcomeNumber, err := qtx.IncrementIssueCounter(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to allocate issue number") return @@ -462,7 +460,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { priority = "high" } welcome, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{ - WorkspaceID: parseUUID(req.WorkspaceID), + WorkspaceID: wsUUID, Title: req.WelcomeIssueTemplate.Title, Description: strOrNullText(req.WelcomeIssueTemplate.Description), Status: "todo", @@ -490,7 +488,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { if sub.Title == "" { continue } - number, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(req.WorkspaceID)) + number, err := qtx.IncrementIssueCounter(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to allocate issue number") return @@ -510,7 +508,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { priority = "none" } issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{ - WorkspaceID: parseUUID(req.WorkspaceID), + WorkspaceID: wsUUID, Title: sub.Title, Description: strOrNullText(sub.Description), Status: status, @@ -538,7 +536,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { pinnedProjectPos := float64(1) var pinProjectForEvent *db.PinnedItem pinProject, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{ - WorkspaceID: parseUUID(req.WorkspaceID), + WorkspaceID: wsUUID, UserID: parseUUID(userID), ItemType: "project", ItemID: project.ID, @@ -552,7 +550,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { var pinWelcomeIssueForEvent *db.PinnedItem if welcomeIssueForEvent != nil { pinWelcome, err := qtx.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{ - WorkspaceID: parseUUID(req.WorkspaceID), + WorkspaceID: wsUUID, UserID: parseUUID(userID), ItemType: "issue", ItemID: welcomeIssueForEvent.ID, @@ -587,7 +585,7 @@ func (h *Handler) ImportStarterContent(w http.ResponseWriter, r *http.Request) { projectResp := projectToResponse(project) h.publish(protocol.EventProjectCreated, req.WorkspaceID, "member", userID, map[string]any{"project": projectResp}) - workspacePrefix := h.getIssuePrefix(r.Context(), parseUUID(req.WorkspaceID)) + workspacePrefix := h.getIssuePrefix(r.Context(), wsUUID) if welcomeIssueForEvent != nil { welcomeResp := issueToResponse(*welcomeIssueForEvent, workspacePrefix) h.publish(protocol.EventIssueCreated, req.WorkspaceID, "member", userID, map[string]any{"issue": welcomeResp}) @@ -675,12 +673,13 @@ func (h *Handler) DismissStarterContent(w http.ResponseWriter, r *http.Request) // ListAgents returns empty. branch := analytics.StarterContentBranchSelfServe if req.WorkspaceID != "" { - if _, err := uuid.Parse(req.WorkspaceID); err == nil { + if wsUUID, err := util.ParseUUID(req.WorkspaceID); err == nil { + req.WorkspaceID = uuidToString(wsUUID) if _, err := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{ UserID: parseUUID(userID), - WorkspaceID: parseUUID(req.WorkspaceID), + WorkspaceID: wsUUID, }); err == nil { - agents, err := h.Queries.ListAgents(r.Context(), parseUUID(req.WorkspaceID)) + agents, err := h.Queries.ListAgents(r.Context(), wsUUID) if err == nil && len(agents) > 0 { branch = analytics.StarterContentBranchAgentGuided } diff --git a/server/internal/handler/personal_access_token.go b/server/internal/handler/personal_access_token.go index 7f6401acac..f074aa442b 100644 --- a/server/internal/handler/personal_access_token.go +++ b/server/internal/handler/personal_access_token.go @@ -37,8 +37,8 @@ func patToResponse(pat db.PersonalAccessToken) PersonalAccessTokenResponse { } type CreatePATRequest struct { - Name string `json:"name"` - ExpiresInDays *int `json:"expires_in_days"` + Name string `json:"name"` + ExpiresInDays *int `json:"expires_in_days"` } func (h *Handler) CreatePersonalAccessToken(w http.ResponseWriter, r *http.Request) { @@ -77,11 +77,11 @@ func (h *Handler) CreatePersonalAccessToken(w http.ResponseWriter, r *http.Reque } pat, err := h.Queries.CreatePersonalAccessToken(r.Context(), db.CreatePersonalAccessTokenParams{ - UserID: parseUUID(userID), - Name: req.Name, - TokenHash: auth.HashToken(rawToken), + UserID: parseUUID(userID), + Name: req.Name, + TokenHash: auth.HashToken(rawToken), TokenPrefix: prefix, - ExpiresAt: expiresAt, + ExpiresAt: expiresAt, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create token") @@ -120,8 +120,12 @@ func (h *Handler) RevokePersonalAccessToken(w http.ResponseWriter, r *http.Reque } id := chi.URLParam(r, "id") + idUUID, ok := parseUUIDOrBadRequest(w, id, "token id") + if !ok { + return + } if err := h.Queries.RevokePersonalAccessToken(r.Context(), db.RevokePersonalAccessTokenParams{ - ID: parseUUID(id), + ID: idUUID, UserID: parseUUID(userID), }); err != nil { writeError(w, http.StatusInternalServerError, "failed to revoke token") diff --git a/server/internal/handler/pin.go b/server/internal/handler/pin.go index 6493fc2a4b..ab4a84ca1c 100644 --- a/server/internal/handler/pin.go +++ b/server/internal/handler/pin.go @@ -93,18 +93,27 @@ func (h *Handler) CreatePin(w http.ResponseWriter, r *http.Request) { return } + itemUUID, ok := parseUUIDOrBadRequest(w, req.ItemID, "item_id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + // Verify the item exists in this workspace switch req.ItemType { case "issue": if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ - ID: parseUUID(req.ItemID), WorkspaceID: parseUUID(workspaceID), + ID: itemUUID, WorkspaceID: wsUUID, }); err != nil { writeError(w, http.StatusNotFound, "issue not found") return } case "project": if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ - ID: parseUUID(req.ItemID), WorkspaceID: parseUUID(workspaceID), + ID: itemUUID, WorkspaceID: wsUUID, }); err != nil { writeError(w, http.StatusNotFound, "project not found") return @@ -113,7 +122,7 @@ func (h *Handler) CreatePin(w http.ResponseWriter, r *http.Request) { // Get max position to append at end maxPos, err := h.Queries.GetMaxPinnedItemPosition(r.Context(), db.GetMaxPinnedItemPositionParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, UserID: parseUUID(userID), }) if err != nil { @@ -122,10 +131,10 @@ func (h *Handler) CreatePin(w http.ResponseWriter, r *http.Request) { } pin, err := h.Queries.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, UserID: parseUUID(userID), ItemType: req.ItemType, - ItemID: parseUUID(req.ItemID), + ItemID: itemUUID, Position: maxPos + 1, }) if err != nil { @@ -151,11 +160,20 @@ func (h *Handler) DeletePin(w http.ResponseWriter, r *http.Request) { itemType := chi.URLParam(r, "itemType") itemID := chi.URLParam(r, "itemId") + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + itemUUID, ok := parseUUIDOrBadRequest(w, itemID, "item id") + if !ok { + return + } + err := h.Queries.DeletePinnedItem(r.Context(), db.DeletePinnedItemParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, UserID: parseUUID(userID), ItemType: itemType, - ItemID: parseUUID(itemID), + ItemID: itemUUID, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to delete pin") @@ -182,11 +200,20 @@ func (h *Handler) ReorderPins(w http.ResponseWriter, r *http.Request) { return } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + for _, item := range req.Items { + itemUUID, ok := parseUUIDOrBadRequest(w, item.ID, "items[].id") + if !ok { + return + } if err := h.Queries.UpdatePinnedItemPosition(r.Context(), db.UpdatePinnedItemPositionParams{ Position: item.Position, - ID: parseUUID(item.ID), - WorkspaceID: parseUUID(workspaceID), + ID: itemUUID, + WorkspaceID: wsUUID, UserID: parseUUID(userID), }); err != nil { writeError(w, http.StatusInternalServerError, "failed to reorder pins") diff --git a/server/internal/handler/project.go b/server/internal/handler/project.go index bd4ee6694c..1626e9fec7 100644 --- a/server/internal/handler/project.go +++ b/server/internal/handler/project.go @@ -78,6 +78,10 @@ type UpdateProjectRequest struct { func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } var statusFilter pgtype.Text if s := r.URL.Query().Get("status"); s != "" { statusFilter = pgtype.Text{String: s, Valid: true} @@ -87,7 +91,7 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { priorityFilter = pgtype.Text{String: p, Valid: true} } projects, err := h.Queries.ListProjects(r.Context(), db.ListProjectsParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, Status: statusFilter, Priority: priorityFilter, }) @@ -125,8 +129,16 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) + idUUID, ok := parseUUIDOrBadRequest(w, id, "project id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ - ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID), + ID: idUUID, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "project not found") @@ -166,10 +178,18 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) { leadType = pgtype.Text{String: *req.LeadType, Valid: true} } if req.LeadID != nil { - leadID = parseUUID(*req.LeadID) + id, ok := parseUUIDOrBadRequest(w, *req.LeadID, "lead_id") + if !ok { + return + } + leadID = id + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return } project, err := h.Queries.CreateProject(r.Context(), db.CreateProjectParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, Title: req.Title, Description: ptrToText(req.Description), Icon: ptrToText(req.Icon), @@ -190,8 +210,16 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) + idUUID, ok := parseUUIDOrBadRequest(w, id, "project id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } prevProject, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ - ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID), + ID: idUUID, WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "project not found") @@ -253,7 +281,11 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) { } if _, ok := rawFields["lead_id"]; ok { if req.LeadID != nil { - params.LeadID = parseUUID(*req.LeadID) + leadUUID, ok := parseUUIDOrBadRequest(w, *req.LeadID, "lead_id") + if !ok { + return + } + params.LeadID = leadUUID } else { params.LeadID = pgtype.UUID{Valid: false} } @@ -271,9 +303,18 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) { func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") workspaceID := h.resolveWorkspaceID(r) - if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ - ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID), - }); err != nil { + idUUID, ok := parseUUIDOrBadRequest(w, id, "project id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } + project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ + ID: idUUID, WorkspaceID: wsUUID, + }) + if err != nil { writeError(w, http.StatusNotFound, "project not found") return } @@ -281,11 +322,11 @@ func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) { if !ok { return } - if err := h.Queries.DeleteProject(r.Context(), parseUUID(id)); err != nil { + if err := h.Queries.DeleteProject(r.Context(), project.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete project") return } - h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": id}) + h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": uuidToString(project.ID)}) w.WriteHeader(http.StatusNoContent) } @@ -453,7 +494,10 @@ func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) { includeClosed := r.URL.Query().Get("include_closed") == "true" - wsUUID := parseUUID(workspaceID) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } terms := splitSearchTerms(q) sqlQuery, args := buildProjectSearchQuery(q, terms, includeClosed) diff --git a/server/internal/handler/reaction.go b/server/internal/handler/reaction.go index 46857bd4e9..26e23d657e 100644 --- a/server/internal/handler/reaction.go +++ b/server/internal/handler/reaction.go @@ -41,9 +41,17 @@ func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) { } workspaceID := h.resolveWorkspaceID(r) + commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ - ID: parseUUID(commentId), - WorkspaceID: parseUUID(workspaceID), + ID: commentUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "comment not found") @@ -66,7 +74,7 @@ func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) { reaction, err := h.Queries.AddReaction(r.Context(), db.AddReactionParams{ CommentID: comment.ID, - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: wsUUID, ActorType: actorType, ActorID: parseUUID(actorID), Emoji: req.Emoji, @@ -108,9 +116,17 @@ func (h *Handler) RemoveReaction(w http.ResponseWriter, r *http.Request) { } workspaceID := h.resolveWorkspaceID(r) + commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id") + if !ok { + return + } + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ - ID: parseUUID(commentId), - WorkspaceID: parseUUID(workspaceID), + ID: commentUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "comment not found") @@ -143,7 +159,7 @@ func (h *Handler) RemoveReaction(w http.ResponseWriter, r *http.Request) { } h.publish(protocol.EventReactionRemoved, workspaceID, actorType, actorID, map[string]any{ - "comment_id": commentId, + "comment_id": uuidToString(comment.ID), "issue_id": uuidToString(comment.IssueID), "emoji": req.Emoji, "actor_type": actorType, diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go index 34587df69c..0ee1be8533 100644 --- a/server/internal/handler/runtime.go +++ b/server/internal/handler/runtime.go @@ -79,8 +79,12 @@ type RuntimeUsageResponse struct { // same tool). func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) { runtimeID := chi.URLParam(r, "runtimeId") + runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id") + if !ok { + return + } - rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID)) + rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID) if err != nil { writeError(w, http.StatusNotFound, "runtime not found") return @@ -93,7 +97,7 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) { since := parseSinceParam(r, 90) rows, err := h.Queries.ListRuntimeUsage(r.Context(), db.ListRuntimeUsageParams{ - RuntimeID: parseUUID(runtimeID), + RuntimeID: rt.ID, Since: since, }) if err != nil { @@ -102,9 +106,10 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) { } resp := make([]RuntimeUsageResponse, len(rows)) + resolvedRuntimeID := uuidToString(rt.ID) for i, row := range rows { resp[i] = RuntimeUsageResponse{ - RuntimeID: runtimeID, + RuntimeID: resolvedRuntimeID, Date: row.Date.Time.Format("2006-01-02"), Provider: row.Provider, Model: row.Model, @@ -121,8 +126,12 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) { // GetRuntimeTaskActivity returns hourly task activity distribution for a runtime. func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request) { runtimeID := chi.URLParam(r, "runtimeId") + runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id") + if !ok { + return + } - rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID)) + rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID) if err != nil { writeError(w, http.StatusNotFound, "runtime not found") return @@ -132,7 +141,7 @@ func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request) return } - rows, err := h.Queries.GetRuntimeTaskHourlyActivity(r.Context(), parseUUID(runtimeID)) + rows, err := h.Queries.GetRuntimeTaskHourlyActivity(r.Context(), rt.ID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get task activity") return @@ -276,8 +285,12 @@ func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) { // DeleteAgentRuntime deletes a runtime after permission and dependency checks. func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) { runtimeID := chi.URLParam(r, "runtimeId") + runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id") + if !ok { + return + } - rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID)) + rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID) if err != nil { writeError(w, http.StatusNotFound, "runtime not found") return @@ -320,7 +333,7 @@ func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) { return } - slog.Info("runtime deleted", "runtime_id", runtimeID, "deleted_by", userID) + slog.Info("runtime deleted", "runtime_id", uuidToString(rt.ID), "deleted_by", userID) // Notify frontend to refresh runtime list. h.publish(protocol.EventDaemonRegister, wsID, "member", userID, map[string]any{ diff --git a/server/internal/handler/runtime_local_skills.go b/server/internal/handler/runtime_local_skills.go index 18059ae17d..f80c75fdb1 100644 --- a/server/internal/handler/runtime_local_skills.go +++ b/server/internal/handler/runtime_local_skills.go @@ -10,6 +10,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/multica-ai/multica/server/internal/util" "github.com/multica-ai/multica/server/pkg/protocol" ) @@ -387,7 +388,12 @@ func runtimeLocalSkillRequestTerminal(status RuntimeLocalSkillRequestStatus) boo } func (h *Handler) requireRuntimeLocalSkillAccess(w http.ResponseWriter, r *http.Request, runtimeID string) (runtimeIDAndWorkspace, bool) { - rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID)) + runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id") + if !ok { + return runtimeIDAndWorkspace{}, false + } + + rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID) if err != nil { writeError(w, http.StatusNotFound, "runtime not found") return runtimeIDAndWorkspace{}, false @@ -401,7 +407,7 @@ func (h *Handler) requireRuntimeLocalSkillAccess(w http.ResponseWriter, r *http. if rt.OwnerID.Valid && uuidToString(rt.OwnerID) == uuidToString(member.UserID) { return runtimeIDAndWorkspace{ - runtimeID: runtimeID, + runtimeID: uuidToString(rt.ID), workspaceID: wsID, provider: rt.Provider, status: rt.Status, @@ -430,7 +436,7 @@ func (h *Handler) InitiateListLocalSkills(w http.ResponseWriter, r *http.Request return } - req, err := h.LocalSkillListStore.Create(r.Context(), runtimeID) + req, err := h.LocalSkillListStore.Create(r.Context(), rt.runtimeID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to enqueue local skills request: "+err.Error()) return @@ -440,7 +446,8 @@ func (h *Handler) InitiateListLocalSkills(w http.ResponseWriter, r *http.Request func (h *Handler) GetLocalSkillListRequest(w http.ResponseWriter, r *http.Request) { runtimeID := chi.URLParam(r, "runtimeId") - if _, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID); !ok { + rt, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID) + if !ok { return } @@ -450,7 +457,7 @@ func (h *Handler) GetLocalSkillListRequest(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusInternalServerError, "failed to load request: "+err.Error()) return } - if req == nil || req.RuntimeID != runtimeID { + if req == nil || req.RuntimeID != rt.runtimeID { writeError(w, http.StatusNotFound, "request not found") return } @@ -486,7 +493,7 @@ func (h *Handler) InitiateImportLocalSkill(w http.ResponseWriter, r *http.Reques importReq, err := h.LocalSkillImportStore.Create( r.Context(), - runtimeID, + rt.runtimeID, creatorID, strings.TrimSpace(req.SkillKey), cleanOptionalString(req.Name), @@ -501,7 +508,8 @@ func (h *Handler) InitiateImportLocalSkill(w http.ResponseWriter, r *http.Reques func (h *Handler) GetLocalSkillImportRequest(w http.ResponseWriter, r *http.Request) { runtimeID := chi.URLParam(r, "runtimeId") - if _, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID); !ok { + rt, ok := h.requireRuntimeLocalSkillAccess(w, r, runtimeID) + if !ok { return } @@ -511,7 +519,7 @@ func (h *Handler) GetLocalSkillImportRequest(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusInternalServerError, "failed to load request: "+err.Error()) return } - if req == nil || req.RuntimeID != runtimeID { + if req == nil || req.RuntimeID != rt.runtimeID { writeError(w, http.StatusNotFound, "request not found") return } @@ -629,6 +637,15 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) return } + creatorUUID, err := util.ParseUUID(req.CreatorID) + if err != nil { + failMsg := "stored local skill import creator_id is invalid" + if ferr := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); ferr != nil { + slog.Error("local skill import Fail failed", "error", ferr, "request_id", requestID) + } + writeError(w, http.StatusInternalServerError, failMsg) + return + } name := body.Skill.Name if req.Name != nil { @@ -648,8 +665,8 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re } resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{ - WorkspaceID: uuidToString(rt.WorkspaceID), - CreatorID: req.CreatorID, + WorkspaceID: rt.WorkspaceID, + CreatorID: creatorUUID, Name: name, Description: description, Content: body.Skill.Content, diff --git a/server/internal/handler/runtime_models.go b/server/internal/handler/runtime_models.go index 0177ad25d2..ca3a0707cb 100644 --- a/server/internal/handler/runtime_models.go +++ b/server/internal/handler/runtime_models.go @@ -193,8 +193,12 @@ func (s *ModelListStore) Fail(id string, errMsg string) { // Called by the frontend; the daemon picks it up on its next heartbeat. func (h *Handler) InitiateListModels(w http.ResponseWriter, r *http.Request) { runtimeID := chi.URLParam(r, "runtimeId") + runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id") + if !ok { + return + } - rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID)) + rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID) if err != nil { writeError(w, http.StatusNotFound, "runtime not found") return @@ -207,7 +211,7 @@ func (h *Handler) InitiateListModels(w http.ResponseWriter, r *http.Request) { return } - req := h.ModelListStore.Create(runtimeID) + req := h.ModelListStore.Create(uuidToString(rt.ID)) writeJSON(w, http.StatusOK, req) } diff --git a/server/internal/handler/runtime_test.go b/server/internal/handler/runtime_test.go index 1d4c041ff7..080e5818a5 100644 --- a/server/internal/handler/runtime_test.go +++ b/server/internal/handler/runtime_test.go @@ -9,6 +9,64 @@ import ( "time" ) +func TestRuntimeHandlersRejectMalformedRuntimeID(t *testing.T) { + tests := []struct { + name string + method string + path string + handle func(http.ResponseWriter, *http.Request) + }{ + { + name: "usage", + method: "GET", + path: "/api/runtimes/not-a-uuid/usage", + handle: testHandler.GetRuntimeUsage, + }, + { + name: "task activity", + method: "GET", + path: "/api/runtimes/not-a-uuid/task-activity", + handle: testHandler.GetRuntimeTaskActivity, + }, + { + name: "delete", + method: "DELETE", + path: "/api/runtimes/not-a-uuid", + handle: testHandler.DeleteAgentRuntime, + }, + { + name: "models", + method: "POST", + path: "/api/runtimes/not-a-uuid/models", + handle: testHandler.InitiateListModels, + }, + { + name: "update", + method: "POST", + path: "/api/runtimes/not-a-uuid/update", + handle: testHandler.InitiateUpdate, + }, + { + name: "local skills", + method: "POST", + path: "/api/runtimes/not-a-uuid/local-skills", + handle: testHandler.InitiateListLocalSkills, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest(tt.method, tt.path, nil) + req = withURLParam(req, "runtimeId", "not-a-uuid") + tt.handle(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("%s: expected 400 for malformed runtimeId, got %d: %s", tt.name, w.Code, w.Body.String()) + } + }) + } +} + // TestGetRuntimeUsage_BucketsByUsageTime ensures a task that was enqueued on // one calendar day but whose tokens were reported the next day (e.g. execution // crossed midnight, or the task sat in the queue) is attributed to the day @@ -78,7 +136,7 @@ func TestGetRuntimeUsage_BucketsByUsageTime(t *testing.T) { return taskID } - insertTaskWithUsage(yesterdayLate, todayEarly, 1000) // cross-midnight + insertTaskWithUsage(yesterdayLate, todayEarly, 1000) // cross-midnight insertTaskWithUsage(yesterdayMorning, yesterdayMorning, 2000) // full-day yesterday // Call the handler with ?days=1 at whatever "now" is. That should include diff --git a/server/internal/handler/runtime_update.go b/server/internal/handler/runtime_update.go index d1f4dd8e5a..198cdde975 100644 --- a/server/internal/handler/runtime_update.go +++ b/server/internal/handler/runtime_update.go @@ -144,8 +144,12 @@ func (s *UpdateStore) Fail(id string, errMsg string) { // InitiateUpdate creates a new CLI update request (protected route, called by frontend). func (h *Handler) InitiateUpdate(w http.ResponseWriter, r *http.Request) { runtimeID := chi.URLParam(r, "runtimeId") + runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id") + if !ok { + return + } - rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID)) + rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID) if err != nil { writeError(w, http.StatusNotFound, "runtime not found") return @@ -167,7 +171,7 @@ func (h *Handler) InitiateUpdate(w http.ResponseWriter, r *http.Request) { return } - update, err := h.UpdateStore.Create(runtimeID, req.TargetVersion) + update, err := h.UpdateStore.Create(uuidToString(rt.ID), req.TargetVersion) if err != nil { writeError(w, http.StatusConflict, err.Error()) return diff --git a/server/internal/handler/skill.go b/server/internal/handler/skill.go index 67ab1005b7..05005eb1e0 100644 --- a/server/internal/handler/skill.go +++ b/server/internal/handler/skill.go @@ -190,6 +190,11 @@ func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) { if !ok { return } + workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } + creatorUUID := parseUUID(creatorID) var req CreateSkillRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -210,8 +215,8 @@ func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) { } resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{ - WorkspaceID: workspaceID, - CreatorID: creatorID, + WorkspaceID: workspaceUUID, + CreatorID: creatorUUID, Name: req.Name, Description: req.Description, Content: req.Content, @@ -360,12 +365,12 @@ func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) { return } - if err := h.Queries.DeleteSkill(r.Context(), parseUUID(id)); err != nil { + if err := h.Queries.DeleteSkill(r.Context(), skill.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete skill") return } actorType, actorID := h.resolveActor(r, requestUserID(r), uuidToString(skill.WorkspaceID)) - h.publish(protocol.EventSkillDeleted, uuidToString(skill.WorkspaceID), actorType, actorID, map[string]any{"skill_id": id}) + h.publish(protocol.EventSkillDeleted, uuidToString(skill.WorkspaceID), actorType, actorID, map[string]any{"skill_id": uuidToString(skill.ID)}) w.WriteHeader(http.StatusNoContent) } @@ -1073,6 +1078,11 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) { if !ok { return } + workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } + creatorUUID := parseUUID(creatorID) var req ImportSkillRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -1112,8 +1122,8 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) { } resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{ - WorkspaceID: workspaceID, - CreatorID: creatorID, + WorkspaceID: workspaceUUID, + CreatorID: creatorUUID, Name: imported.name, Description: imported.description, Content: imported.content, @@ -1200,7 +1210,18 @@ func (h *Handler) DeleteSkillFile(w http.ResponseWriter, r *http.Request) { } fileID := chi.URLParam(r, "fileId") - if err := h.Queries.DeleteSkillFile(r.Context(), parseUUID(fileID)); err != nil { + fileUUID, ok := parseUUIDOrBadRequest(w, fileID, "file id") + if !ok { + return + } + // Verify the file belongs to the parent skill we just authorized — guards + // against deleting a file owned by a different skill via the URL param. + file, err := h.Queries.GetSkillFile(r.Context(), fileUUID) + if err != nil || uuidToString(file.SkillID) != uuidToString(skill.ID) { + writeError(w, http.StatusNotFound, "skill file not found") + return + } + if err := h.Queries.DeleteSkillFile(r.Context(), file.ID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete skill file") return } @@ -1244,6 +1265,10 @@ func (h *Handler) SetAgentSkills(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid request body") return } + skillUUIDs, ok := parseUUIDSliceOrBadRequest(w, req.SkillIDs, "skill_ids") + if !ok { + return + } tx, err := h.TxStarter.Begin(r.Context()) if err != nil { @@ -1259,10 +1284,10 @@ func (h *Handler) SetAgentSkills(w http.ResponseWriter, r *http.Request) { return } - for _, skillID := range req.SkillIDs { + for _, skillID := range skillUUIDs { if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{ AgentID: agent.ID, - SkillID: parseUUID(skillID), + SkillID: skillID, }); err != nil { writeError(w, http.StatusInternalServerError, "failed to add agent skill: "+err.Error()) return diff --git a/server/internal/handler/skill_create.go b/server/internal/handler/skill_create.go index 0e3a70b285..7d8f159604 100644 --- a/server/internal/handler/skill_create.go +++ b/server/internal/handler/skill_create.go @@ -4,12 +4,13 @@ import ( "context" "encoding/json" + "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" ) type skillCreateInput struct { - WorkspaceID string - CreatorID string + WorkspaceID pgtype.UUID + CreatorID pgtype.UUID Name string Description string Content string @@ -35,12 +36,12 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp qtx := h.Queries.WithTx(tx) skill, err := qtx.CreateSkill(ctx, db.CreateSkillParams{ - WorkspaceID: parseUUID(input.WorkspaceID), + WorkspaceID: input.WorkspaceID, Name: input.Name, Description: input.Description, Content: input.Content, Config: config, - CreatedBy: parseUUID(input.CreatorID), + CreatedBy: input.CreatorID, }) if err != nil { return SkillWithFilesResponse{}, err diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go index bf23c35ac0..c6c2222a97 100644 --- a/server/internal/handler/workspace.go +++ b/server/internal/handler/workspace.go @@ -114,8 +114,12 @@ func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) { id := workspaceIDFromURL(r, "id") + idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id") + if !ok { + return + } - ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(id)) + ws, err := h.Queries.GetWorkspace(r.Context(), idUUID) if err != nil { writeError(w, http.StatusNotFound, "workspace not found") return @@ -223,6 +227,10 @@ type UpdateWorkspaceRequest struct { func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { id := workspaceIDFromURL(r, "id") + idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id") + if !ok { + return + } var req UpdateWorkspaceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -231,7 +239,7 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { } params := db.UpdateWorkspaceParams{ - ID: parseUUID(id), + ID: idUUID, } if req.Name != nil { name := strings.TrimSpace(*req.Name) @@ -271,18 +279,19 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { slog.Info("workspace updated", append(logger.RequestAttrs(r), "workspace_id", id)...) userID := requestUserID(r) - h.publish(protocol.EventWorkspaceUpdated, id, "member", userID, map[string]any{"workspace": workspaceToResponse(ws)}) + h.publish(protocol.EventWorkspaceUpdated, uuidToString(ws.ID), "member", userID, map[string]any{"workspace": workspaceToResponse(ws)}) writeJSON(w, http.StatusOK, workspaceToResponse(ws)) } func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) { workspaceID := chi.URLParam(r, "id") - if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok { + member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found") + if !ok { return } - members, err := h.Queries.ListMembers(r.Context(), parseUUID(workspaceID)) + members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list members") return @@ -309,8 +318,12 @@ type MemberWithUserResponse struct { func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) { workspaceID := workspaceIDFromURL(r, "id") + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id") + if !ok { + return + } - members, err := h.Queries.ListMembersWithUser(r.Context(), parseUUID(workspaceID)) + members, err := h.Queries.ListMembersWithUser(r.Context(), wsUUID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list members") return @@ -413,7 +426,7 @@ func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) { } member, err := h.Queries.CreateMember(r.Context(), db.CreateMemberParams{ - WorkspaceID: parseUUID(workspaceID), + WorkspaceID: requester.WorkspaceID, UserID: user.ID, Role: role, }) @@ -430,10 +443,10 @@ func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) { slog.Info("member added", append(logger.RequestAttrs(r), "member_id", uuidToString(member.ID), "workspace_id", workspaceID, "email", email, "role", role)...) userID := requestUserID(r) eventPayload := map[string]any{"member": memberWithUserResponse(member, user)} - if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(workspaceID)); err == nil { + if ws, err := h.Queries.GetWorkspace(r.Context(), requester.WorkspaceID); err == nil { eventPayload["workspace_name"] = ws.Name } - h.publish(protocol.EventMemberAdded, workspaceID, "member", userID, eventPayload) + h.publish(protocol.EventMemberAdded, uuidToString(requester.WorkspaceID), "member", userID, eventPayload) writeJSON(w, http.StatusCreated, memberWithUserResponse(member, user)) } @@ -450,8 +463,12 @@ func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) { } memberID := chi.URLParam(r, "memberId") - target, err := h.Queries.GetMember(r.Context(), parseUUID(memberID)) - if err != nil || uuidToString(target.WorkspaceID) != workspaceID { + memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id") + if !ok { + return + } + target, err := h.Queries.GetMember(r.Context(), memberUUID) + if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) { writeError(w, http.StatusNotFound, "member not found") return } @@ -505,7 +522,7 @@ func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) { } userID := requestUserID(r) - h.publish(protocol.EventMemberUpdated, workspaceID, "member", userID, map[string]any{ + h.publish(protocol.EventMemberUpdated, uuidToString(requester.WorkspaceID), "member", userID, map[string]any{ "member": memberWithUserResponse(updatedMember, user), }) @@ -520,8 +537,12 @@ func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) { } memberID := chi.URLParam(r, "memberId") - target, err := h.Queries.GetMember(r.Context(), parseUUID(memberID)) - if err != nil || uuidToString(target.WorkspaceID) != workspaceID { + memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id") + if !ok { + return + } + target, err := h.Queries.GetMember(r.Context(), memberUUID) + if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) { writeError(w, http.StatusNotFound, "member not found") return } @@ -551,9 +572,9 @@ func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) { slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(target.ID), "workspace_id", workspaceID, "user_id", uuidToString(target.UserID))...) userID := requestUserID(r) - h.publish(protocol.EventMemberRemoved, workspaceID, "member", userID, map[string]any{ + h.publish(protocol.EventMemberRemoved, uuidToString(requester.WorkspaceID), "member", userID, map[string]any{ "member_id": uuidToString(target.ID), - "workspace_id": workspaceID, + "workspace_id": uuidToString(requester.WorkspaceID), "user_id": uuidToString(target.UserID), }) @@ -612,7 +633,9 @@ func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) { return } - if err := h.Queries.DeleteWorkspace(r.Context(), parseUUID(workspaceID)); err != nil { + // At this point workspaceMember has resolved → workspaceID is a valid UUID + // (the lookup would have errored otherwise), so reuse the resolved value. + if err := h.Queries.DeleteWorkspace(r.Context(), requester.WorkspaceID); err != nil { slog.Warn("delete workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...) writeError(w, http.StatusInternalServerError, "failed to delete workspace") return diff --git a/server/internal/middleware/workspace.go b/server/internal/middleware/workspace.go index 13db651ba5..a30eaf8121 100644 --- a/server/internal/middleware/workspace.go +++ b/server/internal/middleware/workspace.go @@ -186,9 +186,19 @@ func buildMiddleware(queries *db.Queries, resolve workspaceResolver, roles []str return } + userUUID, err := util.ParseUUID(userID) + if err != nil { + writeError(w, http.StatusUnauthorized, "user not authenticated") + return + } + wsUUID, err := util.ParseUUID(workspaceID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid workspace_id") + return + } member, err := queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{ - UserID: util.ParseUUID(userID), - WorkspaceID: util.ParseUUID(workspaceID), + UserID: userUUID, + WorkspaceID: wsUUID, }) if err != nil { writeError(w, http.StatusNotFound, "workspace not found") diff --git a/server/internal/util/pgx.go b/server/internal/util/pgx.go index 442576329d..683d3d0815 100644 --- a/server/internal/util/pgx.go +++ b/server/internal/util/pgx.go @@ -2,14 +2,39 @@ package util import ( "encoding/hex" + "fmt" "time" "github.com/jackc/pgx/v5/pgtype" ) -func ParseUUID(s string) pgtype.UUID { +// ParseUUID parses s into a pgtype.UUID. Invalid input returns an error +// instead of a zero-valued UUID — silently dropping bad input has caused +// data-loss bugs (e.g. DELETE matching no rows, returning 204 success). +// +// Use this at any boundary where s comes from user input (URL params, +// request bodies, headers) and pair it with a 4xx response on error. +// For trusted, already-validated UUID strings (sqlc round-trips, fixtures), +// use MustParseUUID instead. +func ParseUUID(s string) (pgtype.UUID, error) { var u pgtype.UUID - _ = u.Scan(s) + if err := u.Scan(s); err != nil { + return u, fmt.Errorf("invalid UUID %q: %w", s, err) + } + if !u.Valid { + return u, fmt.Errorf("invalid UUID: %q", s) + } + return u, nil +} + +// MustParseUUID parses s into a pgtype.UUID and panics on invalid input. +// Reserve for trusted callers (already-validated round-trips, test fixtures). +// At a request boundary, use ParseUUID and surface a 4xx instead. +func MustParseUUID(s string) pgtype.UUID { + u, err := ParseUUID(s) + if err != nil { + panic(err) + } return u } diff --git a/server/internal/util/pgx_test.go b/server/internal/util/pgx_test.go new file mode 100644 index 0000000000..e1298f3498 --- /dev/null +++ b/server/internal/util/pgx_test.go @@ -0,0 +1,47 @@ +package util + +import "testing" + +func TestParseUUID_Valid(t *testing.T) { + u, err := ParseUUID("550e8400-e29b-41d4-a716-446655440000") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if !u.Valid { + t.Fatalf("expected u.Valid = true") + } +} + +func TestParseUUID_InvalidReturnsError(t *testing.T) { + cases := []string{"", "not-a-uuid", "MUL-123", "12345"} + for _, s := range cases { + t.Run(s, func(t *testing.T) { + u, err := ParseUUID(s) + if err == nil { + t.Fatalf("expected error for %q, got nil (u.Valid=%v)", s, u.Valid) + } + if u.Valid { + // Critical invariant: invalid input must NOT yield a valid UUID. + // Returning a valid zero-UUID was the root cause of #1661. + t.Fatalf("expected u.Valid = false for %q, got true", s) + } + }) + } +} + +func TestMustParseUUID_PanicsOnInvalid(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected MustParseUUID to panic on invalid input") + } + }() + MustParseUUID("not-a-uuid") +} + +func TestMustParseUUID_RoundTrip(t *testing.T) { + const s = "550e8400-e29b-41d4-a716-446655440000" + u := MustParseUUID(s) + if got := UUIDToString(u); got != s { + t.Fatalf("round-trip mismatch: got %q want %q", got, s) + } +} From b8f661e006886a2fafecd7f7446cb56b647ec522 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:11:10 +0800 Subject: [PATCH 22/38] feat(create-issue): default assignee to last-selected value (#1774) The create-issue modal now remembers the assignee picked at submit time and prefills the picker with that value when the modal next opens. Implemented by tracking lastAssigneeType/Id alongside the draft and seeding clearDraft's reset with those values. --- .../core/issues/stores/draft-store.test.ts | 60 +++++++++++++++++++ packages/core/issues/stores/draft-store.ts | 19 +++++- packages/views/modals/create-issue.test.tsx | 5 ++ packages/views/modals/create-issue.tsx | 2 + 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 packages/core/issues/stores/draft-store.test.ts diff --git a/packages/core/issues/stores/draft-store.test.ts b/packages/core/issues/stores/draft-store.test.ts new file mode 100644 index 0000000000..3cecb6ea35 --- /dev/null +++ b/packages/core/issues/stores/draft-store.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useIssueDraftStore } from "./draft-store"; + +const RESET_STATE = { + draft: { + title: "", + description: "", + status: "todo" as const, + priority: "none" as const, + assigneeType: undefined, + assigneeId: undefined, + dueDate: null, + }, + lastAssigneeType: undefined, + lastAssigneeId: undefined, +}; + +describe("issue draft store — last assignee", () => { + beforeEach(() => { + useIssueDraftStore.setState(RESET_STATE); + }); + + it("clearDraft prefills the next draft with the remembered assignee", () => { + const { setDraft, setLastAssignee, clearDraft } = + useIssueDraftStore.getState(); + + setDraft({ title: "first", assigneeType: "member", assigneeId: "alice" }); + setLastAssignee("member", "alice"); + clearDraft(); + + const { draft } = useIssueDraftStore.getState(); + expect(draft.title).toBe(""); + expect(draft.assigneeType).toBe("member"); + expect(draft.assigneeId).toBe("alice"); + }); + + it("clearDraft yields an empty assignee when none has ever been remembered", () => { + const { setDraft, clearDraft } = useIssueDraftStore.getState(); + + setDraft({ title: "first" }); + clearDraft(); + + const { draft } = useIssueDraftStore.getState(); + expect(draft.assigneeType).toBeUndefined(); + expect(draft.assigneeId).toBeUndefined(); + }); + + it("setLastAssignee(undefined) lets the user opt back out of a default", () => { + const { setLastAssignee, clearDraft } = useIssueDraftStore.getState(); + + setLastAssignee("member", "alice"); + clearDraft(); + expect(useIssueDraftStore.getState().draft.assigneeId).toBe("alice"); + + setLastAssignee(undefined, undefined); + clearDraft(); + expect(useIssueDraftStore.getState().draft.assigneeId).toBeUndefined(); + expect(useIssueDraftStore.getState().draft.assigneeType).toBeUndefined(); + }); +}); diff --git a/packages/core/issues/stores/draft-store.ts b/packages/core/issues/stores/draft-store.ts index 7ca50530a1..3e168691c2 100644 --- a/packages/core/issues/stores/draft-store.ts +++ b/packages/core/issues/stores/draft-store.ts @@ -26,8 +26,14 @@ const EMPTY_DRAFT: IssueDraft = { interface IssueDraftStore { draft: IssueDraft; + // Last assignee picked at submit time. Persisted across drafts so the + // create-issue modal can prefill the picker with the user's most recent + // choice instead of always opening with no assignee. + lastAssigneeType?: IssueAssigneeType; + lastAssigneeId?: string; setDraft: (patch: Partial) => void; clearDraft: () => void; + setLastAssignee: (type?: IssueAssigneeType, id?: string) => void; hasDraft: () => boolean; } @@ -35,9 +41,20 @@ export const useIssueDraftStore = create()( persist( (set, get) => ({ draft: { ...EMPTY_DRAFT }, + lastAssigneeType: undefined, + lastAssigneeId: undefined, setDraft: (patch) => set((s) => ({ draft: { ...s.draft, ...patch } })), - clearDraft: () => set({ draft: { ...EMPTY_DRAFT } }), + clearDraft: () => + set((s) => ({ + draft: { + ...EMPTY_DRAFT, + assigneeType: s.lastAssigneeType, + assigneeId: s.lastAssigneeId, + }, + })), + setLastAssignee: (type, id) => + set({ lastAssigneeType: type, lastAssigneeId: id }), hasDraft: () => { const { draft } = get(); return !!(draft.title || draft.description); diff --git a/packages/views/modals/create-issue.test.tsx b/packages/views/modals/create-issue.test.tsx index 7867042510..acbe4f8b09 100644 --- a/packages/views/modals/create-issue.test.tsx +++ b/packages/views/modals/create-issue.test.tsx @@ -8,6 +8,7 @@ const mockPush = vi.hoisted(() => vi.fn()); const mockCreateIssue = vi.hoisted(() => vi.fn()); const mockSetDraft = vi.hoisted(() => vi.fn()); const mockClearDraft = vi.hoisted(() => vi.fn()); +const mockSetLastAssignee = vi.hoisted(() => vi.fn()); const mockToastCustom = vi.hoisted(() => vi.fn()); const mockToastDismiss = vi.hoisted(() => vi.fn()); const mockToastError = vi.hoisted(() => vi.fn()); @@ -22,8 +23,11 @@ const mockDraftStore = { assigneeId: undefined, dueDate: null, }, + lastAssigneeType: undefined, + lastAssigneeId: undefined, setDraft: mockSetDraft, clearDraft: mockClearDraft, + setLastAssignee: mockSetLastAssignee, }; vi.mock("../navigation", () => ({ @@ -238,6 +242,7 @@ describe("CreateIssueModal", () => { }); }); + expect(mockSetLastAssignee).toHaveBeenCalledWith(undefined, undefined); expect(mockClearDraft).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); expect(mockToastCustom).toHaveBeenCalledTimes(1); diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx index 824bc4ba8c..4cc5e40d16 100644 --- a/packages/views/modals/create-issue.tsx +++ b/packages/views/modals/create-issue.tsx @@ -57,6 +57,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? const draft = useIssueDraftStore((s) => s.draft); const setDraft = useIssueDraftStore((s) => s.setDraft); const clearDraft = useIssueDraftStore((s) => s.clearDraft); + const setLastAssignee = useIssueDraftStore((s) => s.setLastAssignee); const [title, setTitle] = useState(draft.title); const descEditorRef = useRef(null); @@ -153,6 +154,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? } } + setLastAssignee(assigneeType, assigneeId); clearDraft(); const shouldShowBacklogHint = status === "backlog" && assigneeType === "agent" && assigneeId && From 6ef711cd356c8387e4eaee6d94ce1a713e3c9343 Mon Sep 17 00:00:00 2001 From: devv-eve Date: Tue, 28 Apr 2026 15:14:07 +0800 Subject: [PATCH 23/38] fix: gate dev verification code behind explicit env (#1773) * fix: gate dev verification code behind explicit env * docs: fold dev verification code into env table * docs: clarify fixed verification code opt-in --------- Co-authored-by: Eve --- .env.example | 18 ++-- CONTRIBUTING.md | 7 +- Makefile | 4 +- SELF_HOSTING.md | 10 +-- SELF_HOSTING_ADVANCED.md | 2 +- SELF_HOSTING_AI.md | 2 +- apps/docs/content/docs/auth-setup.mdx | 19 ++-- apps/docs/content/docs/auth-setup.zh.mdx | 21 +++-- apps/docs/content/docs/auth-tokens.mdx | 2 +- apps/docs/content/docs/auth-tokens.zh.mdx | 2 +- .../content/docs/environment-variables.mdx | 9 +- .../content/docs/environment-variables.zh.mdx | 9 +- .../docs/getting-started/self-hosting.zh.mdx | 10 +-- .../content/docs/self-host-quickstart.mdx | 11 +-- .../content/docs/self-host-quickstart.zh.mdx | 11 +-- apps/docs/content/docs/troubleshooting.mdx | 17 ++-- apps/docs/content/docs/troubleshooting.zh.mdx | 19 ++-- docker-compose.selfhost.yml | 1 + docs/docs-outline.md | 12 +-- docs/docs-rewrite-plan.md | 6 +- scripts/init-worktree-env.sh | 1 + scripts/install.ps1 | 2 +- scripts/install.sh | 2 +- server/cmd/server/main.go | 7 ++ server/internal/handler/auth.go | 37 +++++++- server/internal/handler/handler_test.go | 89 +++++++++++++++++++ 26 files changed, 240 insertions(+), 90 deletions(-) diff --git a/.env.example b/.env.example index 32bd6bd948..1662417c41 100644 --- a/.env.example +++ b/.env.example @@ -11,16 +11,15 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable # 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. +# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to +# "production" by default. Local dev can leave it unset. # See SELF_HOSTING.md for the full login setup. APP_ENV= +# Optional local/testing shortcut. Empty by default, so there is no fixed +# verification code. Without RESEND_API_KEY, generated codes print to stdout. +# If you need deterministic local automation, set a 6-digit value such as +# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production. +MULTICA_DEV_VERIFICATION_CODE= PORT=8080 # Prometheus metrics are disabled by default. When enabled, bind to loopback # unless you protect the listener with private networking, allowlists, or @@ -50,8 +49,7 @@ MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web # 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 — generated codes print to stdout. # For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account. RESEND_API_KEY= RESEND_FROM_EMAIL=noreply@multica.ai diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b15178db9d..753dd47f7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -373,7 +373,8 @@ done #### 2. Create a test user and token (automated auth) -In non-production environments the verification code is fixed at `888888`: +For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888` +in your env file before starting the backend: ```bash curl -s -X POST "$SERVER/auth/send-code" \ @@ -476,7 +477,9 @@ This automatically: 3. Starts and manages its own daemon instance 4. Connects to the local backend -Login in the Desktop UI with `dev@localhost` and code `888888`. +Login in the Desktop UI with `dev@localhost` and the generated code from the +backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting +the backend, you can use `888888` instead. If the backend runs on a non-default port (worktree), create `apps/desktop/.env.development.local`: diff --git a/Makefile b/Makefile index 6d64b17c5c..ea2c58f038 100644 --- a/Makefile +++ b/Makefile @@ -91,7 +91,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \ 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 " or read the generated code from backend logs when Resend is unset."; \ echo ""; \ echo "Next — install the CLI and connect your machine:"; \ echo " brew install multica-ai/tap/multica"; \ @@ -130,7 +130,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel 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 " or read the generated code from backend logs when Resend is unset."; \ echo ""; \ echo "Built images locally via docker-compose.selfhost.build.yml."; \ echo "Local tags: multica-backend:dev and multica-web:dev."; \ diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 653bcd0a22..40dc5cff16 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -26,7 +26,7 @@ multica setup self-host This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. -Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details. +Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details. > **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing. > @@ -67,15 +67,15 @@ Once ready: ### Step 2 — Log In -Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in: +Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in: - **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication). -- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address. -- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine. +- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine. +- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`. Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed. -> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`. +> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code. ### Step 3 — Install CLI & Start Daemon diff --git a/SELF_HOSTING_ADVANCED.md b/SELF_HOSTING_ADVANCED.md index 59625be5a3..eb3ceae0af 100644 --- a/SELF_HOSTING_ADVANCED.md +++ b/SELF_HOSTING_ADVANCED.md @@ -32,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c | `RESEND_API_KEY` | Your Resend API key | | `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) | -> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance. +> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`. ### Google OAuth (Optional) diff --git a/SELF_HOSTING_AI.md b/SELF_HOSTING_AI.md index 2c534c4151..ea0cbcbdbb 100644 --- a/SELF_HOSTING_AI.md +++ b/SELF_HOSTING_AI.md @@ -37,7 +37,7 @@ multica setup self-host The `multica setup self-host` command will: 1. Configure CLI to connect to localhost:8080 / localhost:3000 -2. Open a browser for login — use verification code `888888` with any email +2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset 3. Discover workspaces automatically 4. Start the daemon in the background diff --git a/apps/docs/content/docs/auth-setup.mdx b/apps/docs/content/docs/auth-setup.mdx index 78aaaf6acf..7d8fb29085 100644 --- a/apps/docs/content/docs/auth-setup.mdx +++ b/apps/docs/content/docs/auth-setup.mdx @@ -1,6 +1,6 @@ --- title: Sign-in and signup configuration -description: Configure email + verification code sign-in, Google OAuth, and signup allowlists. Avoid the 888888 trap. +description: Configure email + verification code sign-in, Google OAuth, signup allowlists, and local test codes. --- import { Callout } from "fumadocs-ui/components/callout"; @@ -27,17 +27,24 @@ The user enters an email on the sign-in page → the server sends a 6-digit code **What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole. -## The 888888 trap +## Fixed local testing codes -**If `APP_ENV` is not set to `production`, anyone can sign in to any account with the code `888888`.** +**Do not enable a fixed verification code on a publicly reachable instance.** -Multica has a development-only master code, `888888` — a backdoor so local development doesn't depend on Resend. The rule is straightforward: when `APP_ENV != "production"`, **any email** plus `888888` passes verification. +The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code. -**Production deployments must set `APP_ENV=production`**. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, this value is already set to `production` by default; but if you deploy from source yourself, write your own Docker config, or redefine environment variables in Kubernetes — you must add `APP_ENV=production` yourself. +Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production: + +```bash +APP_ENV=development +MULTICA_DEV_VERIFICATION_CODE=888888 +``` + +This shortcut is ignored when `APP_ENV=production`. -To check whether your deployment has this trap: open the sign-in page, enter **any email** to request a code, then enter `888888`. If you get in, your `APP_ENV` is not set to `production`, and **the entire instance is wide open**. +Production deployments should leave `MULTICA_DEV_VERIFICATION_CODE` empty and set `APP_ENV=production`. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, `APP_ENV` defaults to `production`. ## Google OAuth configuration diff --git a/apps/docs/content/docs/auth-setup.zh.mdx b/apps/docs/content/docs/auth-setup.zh.mdx index aabe40d829..7ce8115361 100644 --- a/apps/docs/content/docs/auth-setup.zh.mdx +++ b/apps/docs/content/docs/auth-setup.zh.mdx @@ -1,12 +1,12 @@ --- title: 登录与注册配置 -description: 配 Email 验证码登录 + Google OAuth + 注册白名单。避开最坑的 888888 陷阱。 +description: 配 Email 验证码登录、Google OAuth、注册白名单和本地测试验证码。 --- import { Callout } from "fumadocs-ui/components/callout"; import { Mermaid } from "@/components/mermaid"; -Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及自部署最容易踩的一个陷阱。 +Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及本地测试验证码怎么安全使用。 上面用到的环境变量的清单见 [环境变量](/environment-variables);token 怎么用、生命周期细节见 [认证与令牌](/auth-tokens)。 @@ -27,17 +27,24 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google **不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。 -## 888888 陷阱 +## 固定本地测试验证码 -**`APP_ENV` 不设为 `production`,任何人都能用验证码 `888888` 登录任何账号。** +**不要在公网可访问实例上启用固定验证码。** -Multica 有一个开发用的主验证码(master code)`888888`——为了本地开发不依赖 Resend 而设的后门。判定逻辑很简单:`APP_ENV != "production"` 时,**任何邮箱**输 `888888` 都能通过。 +旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。 -**生产部署必须设 `APP_ENV=production`**。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,这个值已经默认设为 `production`;但如果你自己从源码部署、自己写 Docker 配置、或者在 Kubernetes 里重新定义环境变量——一定要自己把 `APP_ENV=production` 加上。 +不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production: + +```bash +APP_ENV=development +MULTICA_DEV_VERIFICATION_CODE=888888 +``` + +`APP_ENV=production` 时这个快捷码会被忽略。 -检查你的部署是否有这个陷阱:打开登录页,输入**任意邮箱**请求验证码,再在验证码栏输 `888888`。如果能登进去 = 你的 `APP_ENV` 没设成 `production`,**整个实例处于完全开放状态**。 +生产部署应保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,并设置 `APP_ENV=production`。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,`APP_ENV` 默认就是 `production`。 ## 怎么配 Google OAuth diff --git a/apps/docs/content/docs/auth-tokens.mdx b/apps/docs/content/docs/auth-tokens.mdx index 3472f9f3a2..27aea8704b 100644 --- a/apps/docs/content/docs/auth-tokens.mdx +++ b/apps/docs/content/docs/auth-tokens.mdx @@ -38,7 +38,7 @@ In day-to-day use you'll only touch the first two directly. The **[daemon](/daem 2. Enter the code; the server issues a JWT cookie (browser) or exchanges it for a PAT (CLI). -**Self-hosting operators, take note**: if `APP_ENV` is not set to `production`, the verification code is always `888888` — anyone can sign in as anyone. See [Self-host auth configuration](/auth-setup). +**Self-hosting operators, take note**: keep `MULTICA_DEV_VERIFICATION_CODE` empty on public deployments. If you enable a fixed local test code, anyone who can request a code can sign in with that value while `APP_ENV` is non-production. See [Self-host auth configuration](/auth-setup). ### Google OAuth diff --git a/apps/docs/content/docs/auth-tokens.zh.mdx b/apps/docs/content/docs/auth-tokens.zh.mdx index 0c04e01ba5..e907bfb23f 100644 --- a/apps/docs/content/docs/auth-tokens.zh.mdx +++ b/apps/docs/content/docs/auth-tokens.zh.mdx @@ -38,7 +38,7 @@ Multica 有三种令牌,对应三种使用场景:浏览器 Web UI、命令 2. 输入验证码,server 签发 JWT cookie(浏览器)或交换出 PAT(CLI) -**自部署运维注意**:如果环境变量 `APP_ENV` 不是 `production`,验证码恒为 `888888`——任何人能登录任何账号。详见 [自部署的认证配置](/auth-setup)。 +**自部署运维注意**:公网部署时保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。如果启用固定本地测试验证码,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用该固定值登录。详见 [自部署的认证配置](/auth-setup)。 ### Google OAuth diff --git a/apps/docs/content/docs/environment-variables.mdx b/apps/docs/content/docs/environment-variables.mdx index 81e03c6a83..88b4cb60d4 100644 --- a/apps/docs/content/docs/environment-variables.mdx +++ b/apps/docs/content/docs/environment-variables.mdx @@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout"; A self-hosted Multica [server](/self-host-quickstart) reads its configuration from environment variables at startup — database, sign-in, email, storage, signup allowlists all live here. This page groups every variable by purpose: each section spells out **what happens if you leave it unset** and **which ones you must set in production**. For how to actually configure the auth-related ones, see [Sign-in and signup configuration](/auth-setup). -## The five required at startup +## Core server variables -These are the five you must think about before deploying — some have defaults that let the server start, but in production you should set all of them explicitly. +These are the core variables you must think about before deploying — some have defaults that let the server start, but in production you should set the required ones explicitly. | Variable | Default | Required in production? | |---|---|---| | `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **Yes** | | `PORT` | `8080` | No (unless you change the port) | | `JWT_SECRET` | `multica-dev-secret-change-in-production` | **Yes** (the default is unsafe) | -| `APP_ENV` | empty | **Yes** (must be `production` — see the next section for the trap) | +| `APP_ENV` | empty | **Yes** (must be `production`) | | `FRONTEND_ORIGIN` | empty | **Yes** (self-host must set its own domain) | +| `MULTICA_DEV_VERIFICATION_CODE` | empty | No (must stay empty in production) | -**If `APP_ENV` is not set to `production`, anyone can sign in to any account using the code `888888`.** Multica has a development-only master code, `888888` — when `APP_ENV != "production"`, **any email** plus `888888` passes verification. The behavior is intentional for local development (no Resend dependency); **in production, failing to set `production` is equivalent to disabling auth entirely**. See [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap). +**Keep `MULTICA_DEV_VERIFICATION_CODE` empty in production.** A fixed local test code is disabled by default, but if you opt in with `MULTICA_DEV_VERIFICATION_CODE=888888`, anyone who can request a code can sign in with that fixed value while `APP_ENV` is non-production. The shortcut is ignored when `APP_ENV=production`. ### Database connection pool diff --git a/apps/docs/content/docs/environment-variables.zh.mdx b/apps/docs/content/docs/environment-variables.zh.mdx index a2388bc740..abde447ed7 100644 --- a/apps/docs/content/docs/environment-variables.zh.mdx +++ b/apps/docs/content/docs/environment-variables.zh.mdx @@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout"; Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量读取配置——数据库、登录、邮件、存储、注册白名单都在这里配。这一页按用途分组给完整清单:每组说清楚**不设会怎样**、**生产必须设哪几个**。Auth 相关那几个怎么真正配见 [登录与注册配置](/auth-setup)。 -## 启动必填的五个 +## 核心 server 环境变量 -这五个是你部署前必须考虑的——有些有默认值能让 server 启动,但生产环境里你应该全部显式配。 +这些是你部署前必须考虑的核心变量——有些有默认值能让 server 启动,但生产环境里你应该显式配置必填项。 | 环境变量 | 默认值 | 生产必须设? | |---|---|---| | `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **是** | | `PORT` | `8080` | 否(除非换端口)| | `JWT_SECRET` | `multica-dev-secret-change-in-production` | **是**(默认值不安全)| -| `APP_ENV` | 空 | **是**(必须 `production`——见下一节陷阱)| +| `APP_ENV` | 空 | **是**(必须 `production`)| | `FRONTEND_ORIGIN` | 空 | **是**(self-host 要填你自己的域名)| +| `MULTICA_DEV_VERIFICATION_CODE` | 空 | 否(生产必须保持为空)| -**`APP_ENV` 不设为 `production`,任何人都能用 `888888` 登录任何账号。** Multica 有一个开发用的主验证码(master code)`888888`——`APP_ENV != "production"` 时**任何邮箱**输 `888888` 都能通过。本地开发时故意留空方便调试;**生产环境一旦不设 `production`,等于 auth 完全失效**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。 +**生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。** 固定本地测试验证码默认关闭;如果你设置 `MULTICA_DEV_VERIFICATION_CODE=888888`,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用这个固定值登录。`APP_ENV=production` 时该快捷码会被忽略。 ### 数据库连接池 diff --git a/apps/docs/content/docs/getting-started/self-hosting.zh.mdx b/apps/docs/content/docs/getting-started/self-hosting.zh.mdx index 96700ba00d..acf33b9962 100644 --- a/apps/docs/content/docs/getting-started/self-hosting.zh.mdx +++ b/apps/docs/content/docs/getting-started/self-hosting.zh.mdx @@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins multica setup self-host ``` -This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details. +This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from backend logs. See [Step 2 — Log In](#step-2--log-in) for details. If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`. @@ -68,16 +68,16 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`, ### Step 2 — Log In -Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in: +Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in: - **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below. -- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address. -- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine. +- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine. +- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`. Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed. -**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`. +**Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code. ### Step 3 — Install CLI & Start Daemon diff --git a/apps/docs/content/docs/self-host-quickstart.mdx b/apps/docs/content/docs/self-host-quickstart.mdx index e5be0cf432..451818f149 100644 --- a/apps/docs/content/docs/self-host-quickstart.mdx +++ b/apps/docs/content/docs/self-host-quickstart.mdx @@ -45,19 +45,19 @@ Once it's up: - **Frontend**: [http://localhost:3000](http://localhost:3000) - **Backend**: [http://localhost:8080](http://localhost:8080) -## 2. Important: set `APP_ENV` to `production` +## 2. Important: keep production safety on -**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** — this prevents the development "master code `888888`" from being enabled on an instance you've exposed to the public internet. +**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** and leaves `MULTICA_DEV_VERIFICATION_CODE` empty, so there is no fixed code on public instances. -**But if your `.env` leaves `APP_ENV` empty or sets it to another value**, `888888` is enabled — **anyone can log in as any email by typing `888888` as the verification code**. See [Auth setup → The 888888 trap](/auth-setup#the-888888-trap). +Only set `MULTICA_DEV_VERIFICATION_CODE` for local or private test automation. If a fixed code is enabled while `APP_ENV` is non-production, anyone who can request a code can sign in with that fixed value. See [Auth setup → Fixed local testing codes](/auth-setup#fixed-local-testing-codes). -Before any public deployment, make sure `.env` has `APP_ENV=production`. +Before any public deployment, make sure `.env` has `APP_ENV=production` and `MULTICA_DEV_VERIFICATION_CODE` is empty. ## 3. Configure the email service (optional but recommended) -Without email configured, your users can't receive verification codes — **unless `APP_ENV != production`, in which case `888888` works** (see the warning above). +Without email configured, your users can't receive verification codes by email; the server prints generated codes to stdout instead. To actually send verification emails: @@ -80,6 +80,7 @@ Open [http://localhost:3000](http://localhost:3000): - Enter your email - Grab the verification code from the Resend email (or, if you haven't configured Resend, from the server container stdout — look for the `[DEV] Verification code` line) +- Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance - Log in and create your first workspace ## 5. Point the CLI at your own server diff --git a/apps/docs/content/docs/self-host-quickstart.zh.mdx b/apps/docs/content/docs/self-host-quickstart.zh.mdx index b333fd8ebd..3d854ed8b6 100644 --- a/apps/docs/content/docs/self-host-quickstart.zh.mdx +++ b/apps/docs/content/docs/self-host-quickstart.zh.mdx @@ -44,19 +44,19 @@ make selfhost - **前端**:[http://localhost:3000](http://localhost:3000) - **后端**:[http://localhost:8080](http://localhost:8080) -## 2. 重要:改 `APP_ENV` 成 `production` +## 2. 重要:保持生产安全配置 -**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**——这防止开发用的"万能验证码 `888888`"在你公网暴露的实例上启用。 +**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**,并让 `MULTICA_DEV_VERIFICATION_CODE` 为空,所以公网实例默认没有固定验证码。 -**但如果你的 `.env` 里把 `APP_ENV` 留空或改成其他值**,`888888` 会被启用——**任何人输入任何邮箱 + `888888` 都能登录**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。 +只在本地或私有测试自动化里设置 `MULTICA_DEV_VERIFICATION_CODE`。如果在 `APP_ENV` 非 production 时启用了固定验证码,任何能请求验证码的人都能用这个固定值登录。详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码)。 -公网部署前一定检查 `.env` 里 `APP_ENV=production`。 +公网部署前一定检查 `.env` 里 `APP_ENV=production`,且 `MULTICA_DEV_VERIFICATION_CODE` 为空。 ## 3. 配置邮件服务(可选但推荐) -如果不配邮件,你的用户无法收到验证码——**但如果 `APP_ENV != production` 你可以用 `888888` 登录**(见上方警告)。 +如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。 要真的发验证码邮件: @@ -79,6 +79,7 @@ make selfhost - 输入你的邮箱 - 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行) +- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效 - 登录后创建第一个工作区 ## 5. 连接命令行工具到你自己的 server diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index 2b0a0a9be4..1c8864e3c2 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -108,28 +108,29 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t - Domain not verified → run the DNS verification flow in the Resend console (add SPF / DKIM records) - In an emergency (internal testing) → copy the code printed under `[DEV]` from the server logs -## Verification code `888888` doesn't work +## Fixed local test code doesn't work -**Symptom**: on a self-hosted instance, you try to sign in with the development-only master code `888888` and it's rejected with `invalid or expired code`. +**Symptom**: on a self-hosted instance, you try to sign in with a fixed local test code such as `888888` and it's rejected with `invalid or expired code`. **Likely causes** (mutually exclusive): -1. **`APP_ENV=production`** — this is the **correct** production configuration; `888888` is **disabled** when `APP_ENV=production`. Intentional design, not a bug -2. **You received a real code via Resend** — if Resend is configured, the server sent an actual email; `888888` is only a dev fallback +1. **`MULTICA_DEV_VERIFICATION_CODE` is empty** — fixed codes are disabled by default +2. **`APP_ENV=production`** — this is the **correct** production configuration; fixed local test codes are ignored in production +3. **The configured code is not 6 digits** — the shortcut only accepts a 6-digit value **How to diagnose**: ```bash -cat .env | grep APP_ENV # inspect current config -docker exec env | grep APP_ENV # docker deployment +cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' +docker exec env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' ``` Check your inbox (including spam) for the real verification code. **How to fix**: -- In production, you shouldn't be using `888888` at all — configure Resend and use real codes -- **For local development or internal testing**, if you need `888888`, ensure `APP_ENV` is unset or not `production` — but **never** run a public instance this way (see [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap)) +- In production, leave `MULTICA_DEV_VERIFICATION_CODE` empty — configure Resend and use real codes +- For local development or internal testing, either copy the generated code from server logs or set `APP_ENV=development` plus `MULTICA_DEV_VERIFICATION_CODE=888888` — never enable a fixed code on a public instance (see [Sign-in and signup configuration → Fixed local testing codes](/auth-setup#fixed-local-testing-codes)) ## Port conflicts diff --git a/apps/docs/content/docs/troubleshooting.zh.mdx b/apps/docs/content/docs/troubleshooting.zh.mdx index 5b3390e34a..ef73228dd3 100644 --- a/apps/docs/content/docs/troubleshooting.zh.mdx +++ b/apps/docs/content/docs/troubleshooting.zh.mdx @@ -108,28 +108,29 @@ multica issue show # 看 task 历史 - 域名没验证 → Resend console 里走 DNS 验证流程(加 SPF / DKIM 记录) - 紧急情况下(如内部测试)→ 从 server 日志里抄 `[DEV]` 打印出的验证码 -## 验证码是 `888888` 但登不进去 +## 固定本地测试验证码登不进去 -**症状**:自部署实例,想用开发用的主验证码 `888888` 登录,但被拒 `invalid or expired code`。 +**症状**:自部署实例,想用 `888888` 这类固定本地测试验证码登录,但被拒 `invalid or expired code`。 -**可能原因**(这俩互斥): +**可能原因**(互斥): -1. **`APP_ENV=production`** —— 这正是你**应该**的生产配置;`888888` 在 `APP_ENV=production` 时**被禁用**。这是刻意设计,不是 bug -2. **你在 Resend 收到了真实验证码** —— 如果 Resend 已配,server 实际发了真邮件,`888888` 只作为 dev fallback +1. **`MULTICA_DEV_VERIFICATION_CODE` 为空** —— 固定验证码默认关闭 +2. **`APP_ENV=production`** —— 这是正确的生产配置;固定本地测试验证码在 production 中会被忽略 +3. **配置的验证码不是 6 位数字** —— 这个快捷码只接受 6 位数字 **怎么查**: ```bash -cat .env | grep APP_ENV # 看当前配置 -docker exec env | grep APP_ENV # docker 部署 +cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' +docker exec env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE' ``` 检查邮箱(含 spam)看有没有收到真实验证码。 **怎么修**: -- 生产环境你本来就不该用 `888888`—— 配好 Resend 用真实验证码 -- **本地开发或内网测试**若需要 `888888`,确保 `APP_ENV` 未设或不是 `production`——但**绝对不要**这样跑公网实例(详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)) +- 生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,配好 Resend 后使用真实验证码 +- 本地开发或内网测试可以从 server 日志抄生成的验证码;如果需要 `888888`,设置 `APP_ENV=development` 和 `MULTICA_DEV_VERIFICATION_CODE=888888`。不要在公网实例启用固定验证码(详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码)) ## 端口冲突 diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 4fee5c8224..acb0df47d3 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -56,6 +56,7 @@ services: CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} APP_ENV: ${APP_ENV:-production} + MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-} MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000} ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true} ALLOWED_EMAILS: ${ALLOWED_EMAILS:-} diff --git a/docs/docs-outline.md b/docs/docs-outline.md index 61b727fbdb..ded80ff7fc 100644 --- a/docs/docs-outline.md +++ b/docs/docs-outline.md @@ -147,7 +147,7 @@ multica issue assign --agent **关键约定**: -- **Callout**:`...`。warning 用于陷阱(如 888888),info 用于补充说明,tip 用于最佳实践 +- **Callout**:`...`。warning 用于陷阱(如固定测试验证码),info 用于补充说明,tip 用于最佳实践 - **代码块**:shell 命令用 \`\`\`bash;配置用 \`\`\`yaml / \`\`\`env;JSON 用 \`\`\`json - **Cross-link**:用 markdown 链接 `[显示文字](/docs/page-slug)`,不要写成 "详见 Tasks 章节" - **表格**:有 3 行以上对照才用表格,不要 1-2 行也用 @@ -723,11 +723,11 @@ multica issue assign --agent > **合并说明**:原 7.3 Auth Setup + 7.10 Signup Controls 合并。 -- **Source files**: `server/internal/handler/auth.go`(APP_ENV 判断 + checkSignupAllowed), `.env.example`(auth 相关注释) +- **Source files**: `server/internal/handler/auth.go`(固定测试验证码 + checkSignupAllowed), `.env.example`(auth 相关注释) - **目标读者**: self-host 运维 - **叙事位置**: self-host 的 auth 配置。 - **写什么**(1500-2000 字): - - **🚨 超醒目 warning block**:`APP_ENV=production` 必须设置,否则 verification code 恒为 `888888`(任何人登录任何账号) + - **🚨 超醒目 warning block**:生产环境必须保持 `MULTICA_DEV_VERIFICATION_CODE` 为空;固定测试验证码只用于非 production 私有测试 - Email + verification code 登录流程(依赖 Resend) - Google OAuth 配置步骤(创建 OAuth client → redirect URI → 填 env) - **Signup 白名单三层优先级决策树**: @@ -737,9 +737,9 @@ multica issue assign --agent - 典型场景:开放给公司域 / 限定几个邮箱 / 完全关闭 signup - 和邀请的关系(signup 关了也能通过邀请加人) - **不写**: JWT 实现、token 类型(§8.2 讲) -- **写前要验证**: APP_ENV 判断条件;OAuth 流程最新;Signup 优先级 +- **写前要验证**: 固定测试验证码的 env 条件;OAuth 流程最新;Signup 优先级 - **⚠️ 动笔前必读**: - - ⚠️⚠️ **888888 陷阱必须最醒目**(红色 warning block),这是 self-host 最大坑 + - ⚠️⚠️ **固定测试验证码风险必须最醒目**(红色 warning block),这是 self-host 最大坑 - OAuth 给外部步骤截图,别假设读者懂 GCP Console - 决策树建议用 Mermaid 图 - **Owner**: – @@ -754,7 +754,7 @@ multica issue assign --agent - 任务一直 queued(runtime offline / max_concurrent 满 / agent 配错) - WebSocket 连不上(cookie / CORS / proxy) - Email 没收到(Resend 未配置 → 看 stderr) - - 验证码收到是 888888 但不工作(APP_ENV 检查) + - 固定测试验证码不工作(APP_ENV / MULTICA_DEV_VERIFICATION_CODE 检查) - Port 冲突 - 日志位置:daemon / server / browser console - **不写**: 深度 bug report(去 GitHub issue) diff --git a/docs/docs-rewrite-plan.md b/docs/docs-rewrite-plan.md index d1dd6ed9a3..ecc07ab1a0 100644 --- a/docs/docs-rewrite-plan.md +++ b/docs/docs-rewrite-plan.md @@ -118,7 +118,7 @@ Multica = **人 + AI agent 在同一个看板上协作的任务管理平台**。 | Overview | 决策树(哪种部署模式适合你) | | Docker Compose deployment | `make selfhost` vs `make selfhost-build` | | Environment variables reference | 完整 env 表 | -| Authentication setup | **🚨 `APP_ENV != "production"` 会让 verification code 固定为 `888888`** —— 生产必须设置 `APP_ENV=production`;Google OAuth 配置;signup 白名单 | +| Authentication setup | **🚨 固定测试验证码必须显式设置 `MULTICA_DEV_VERIFICATION_CODE`,生产保持为空**;Google OAuth 配置;signup 白名单 | | Storage | S3 / CloudFront / 本地磁盘 | | Email | Resend 配置;**没配会落到 stderr** | | Upgrading | 版本升级 + migration 策略 | @@ -145,7 +145,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A | 5 | Webhook autopilot trigger 字段建了但没接路由——第一版不文档化 | Autopilots | | 6 | custom_env merge 是覆盖而非合并——不能用 custom_env"取消设置"系统 env | Agents | | 7 | 旧 assignee 取消分配后不会被取消订阅 | Subscriptions | -| 8 | `APP_ENV != "production"` 时 verification code 恒为 `888888` | Self-Hosting → Auth | +| 8 | 固定本地测试验证码默认关闭;`MULTICA_DEV_VERIFICATION_CODE` 仅用于非 production 私有测试 | Self-Hosting → Auth | | 9 | Signup 白名单优先级:ALLOWED_EMAILS > ALLOWED_EMAIL_DOMAINS > ALLOW_SIGNUP | Self-Hosting → Auth | | 10 | One daemon ↔ many runtimes;one runtime ↔ ONE provider;同 daemon_id 重启复用旧 runtime 行 | Runtimes / Daemon | | 11 | Inbox 10 种类型,mention dedup 只在单 event 内生效 | Inbox | @@ -159,7 +159,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A |---|---| | Mermaid diagram | 架构图 / task 生命周期 / trigger 流向 / autopilot 调度链 | | Tabs | Cloud / Self-Host / Desktop 并列;CLI / UI 并列 | -| Callouts(内置)| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的 888888** | +| Callouts(内置)| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的固定测试验证码** | | Code Tabs | API 调用多语言(Shell / Node / Go) | | Video / GIF | "Create your first agent"、"Follow an agent working" | | DeploymentPicker(定制)| 交互式决策树:回答 3 个问题 → 推荐部署路径 | diff --git a/scripts/init-worktree-env.sh b/scripts/init-worktree-env.sh index b02ebe0f31..095e21a38d 100644 --- a/scripts/init-worktree-env.sh +++ b/scripts/init-worktree-env.sh @@ -32,6 +32,7 @@ DATABASE_URL=postgres://multica:multica@localhost:${postgres_port}/${postgres_db PORT=${backend_port} JWT_SECRET=change-me-in-production +MULTICA_DEV_VERIFICATION_CODE= MULTICA_SERVER_URL=ws://localhost:${backend_port}/ws MULTICA_APP_URL=${frontend_origin} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5233b9166c..c4cab72579 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -433,7 +433,7 @@ function Start-LocalInstall { Write-Host " multica setup self-host " -NoNewline; Write-Host "# Configure + authenticate + start daemon" -ForegroundColor DarkGray Write-Host "" Write-Host " Login: configure RESEND_API_KEY in .env for email codes," - Write-Host " or set APP_ENV=development in .env to enable the dev master code 888888." + Write-Host " or read the generated code from backend logs when Resend is unset." Write-Host "" Write-Host " To stop all services:" Write-Host ' $env:MULTICA_MODE="stop"; irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex' diff --git a/scripts/install.sh b/scripts/install.sh index 4f1c085d45..ebb0d82ca3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -393,7 +393,7 @@ run_with_server() { printf " ${CYAN}multica setup self-host${RESET} # Configure + authenticate + start daemon\n" printf "\n" printf " ${BOLD}Login:${RESET} configure ${CYAN}RESEND_API_KEY${RESET} in .env for email codes,\n" - printf " or set ${CYAN}APP_ENV=development${RESET} in .env to enable the dev master code ${BOLD}888888${RESET}.\n" + printf " or read the generated code from backend logs when Resend is unset.\n" printf "\n" printf " ${BOLD}To stop all services:${RESET}\n" printf " curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop\n" diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 201a5f7bd2..acbb39d73a 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -124,6 +124,13 @@ func main() { if os.Getenv("RESEND_API_KEY") == "" { slog.Warn("RESEND_API_KEY is not set — email verification codes will be printed to the log instead of emailed.") } + if os.Getenv("MULTICA_DEV_VERIFICATION_CODE") != "" { + if strings.EqualFold(strings.TrimSpace(os.Getenv("APP_ENV")), "production") { + slog.Warn("MULTICA_DEV_VERIFICATION_CODE is set but ignored because APP_ENV=production.") + } else { + slog.Warn("MULTICA_DEV_VERIFICATION_CODE is enabled. Use it only for local development or private test instances.") + } + } port := os.Getenv("PORT") if port == "" { diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index 4345b46fe7..d2847ccb34 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -2,11 +2,11 @@ package handler import ( "context" - "errors" "crypto/rand" "crypto/subtle" "encoding/binary" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -36,6 +36,8 @@ func (e SignupError) Error() string { 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"} +const devVerificationCodeEnv = "MULTICA_DEV_VERIFICATION_CODE" + type UserResponse struct { ID string `json:"id"` Name string `json:"name"` @@ -92,6 +94,35 @@ func generateCode() (string, error) { return fmt.Sprintf("%06d", n), nil } +func isDevVerificationCode(code string) bool { + if isProductionEnv() { + return false + } + + devCode := strings.TrimSpace(os.Getenv(devVerificationCodeEnv)) + if !isSixDigitCode(devCode) { + return false + } + + return subtle.ConstantTimeCompare([]byte(code), []byte(devCode)) == 1 +} + +func isProductionEnv() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("APP_ENV")), "production") +} + +func isSixDigitCode(code string) bool { + if len(code) != 6 { + return false + } + for _, ch := range code { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + func (h *Handler) issueJWT(user db.User) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": uuidToString(user.ID), @@ -311,8 +342,8 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { return } - isMasterCode := code == "888888" && os.Getenv("APP_ENV") != "production" - if !isMasterCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 { + isDevCode := isDevVerificationCode(code) + if !isDevCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 { _ = h.Queries.IncrementVerificationCodeAttempts(r.Context(), dbCode.ID) writeError(w, http.StatusBadRequest, "invalid or expired code") return diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 04b70e3802..57d37f75b2 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -1543,7 +1543,94 @@ func TestVerifyCode(t *testing.T) { } } +func createVerificationCodeForTest(t *testing.T, email, code string) { + t.Helper() + + _, err := testPool.Exec(context.Background(), ` + INSERT INTO verification_code (email, code, expires_at) + VALUES ($1, $2, now() + interval '10 minutes') + `, email, code) + if err != nil { + t.Fatalf("create verification code: %v", err) + } +} + +func TestVerifyCodeRejectsDevCodeUnlessExplicitlyConfigured(t *testing.T) { + t.Setenv(devVerificationCodeEnv, "") + t.Setenv("APP_ENV", "") + + const email = "dev-code-disabled-test@multica.ai" + ctx := context.Background() + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email) + }) + + createVerificationCodeForTest(t, email, "123456") + + w := httptest.NewRecorder() + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"}) + req := httptest.NewRequest("POST", "/auth/verify-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.VerifyCode(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("VerifyCode (disabled dev code): expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestVerifyCodeAcceptsConfiguredDevCodeOutsideProduction(t *testing.T) { + t.Setenv(devVerificationCodeEnv, "888888") + t.Setenv("APP_ENV", "development") + + const email = "dev-code-enabled-test@multica.ai" + ctx := context.Background() + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email) + testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email) + }) + + createVerificationCodeForTest(t, email, "123456") + + w := httptest.NewRecorder() + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"}) + req := httptest.NewRequest("POST", "/auth/verify-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.VerifyCode(w, req) + if w.Code != http.StatusOK { + t.Fatalf("VerifyCode (enabled dev code): expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestVerifyCodeRejectsConfiguredDevCodeInProduction(t *testing.T) { + t.Setenv(devVerificationCodeEnv, "888888") + t.Setenv("APP_ENV", "production") + + const email = "dev-code-production-test@multica.ai" + ctx := context.Background() + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email) + }) + + createVerificationCodeForTest(t, email, "123456") + + w := httptest.NewRecorder() + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"}) + req := httptest.NewRequest("POST", "/auth/verify-code", &buf) + req.Header.Set("Content-Type", "application/json") + testHandler.VerifyCode(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("VerifyCode (production dev code): expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + func TestVerifyCodeWrongCode(t *testing.T) { + t.Setenv(devVerificationCodeEnv, "") + const email = "wrong-code-test@multica.ai" ctx := context.Background() @@ -1572,6 +1659,8 @@ func TestVerifyCodeWrongCode(t *testing.T) { } func TestVerifyCodeBruteForceProtection(t *testing.T) { + t.Setenv(devVerificationCodeEnv, "") + const email = "bruteforce-test@multica.ai" ctx := context.Background() From 81231e06f836016da832c237db6372073ece99f7 Mon Sep 17 00:00:00 2001 From: Bright Zheng Date: Tue, 28 Apr 2026 03:14:14 -0400 Subject: [PATCH 24/38] fix(server): prevent agent-to-agent mention inheritance loops (BRI-34) (#1765) When an agent replied in a thread whose root mentioned another agent, the reply inherited the parent mention and re-triggered the other agent. This caused 'No reply needed' ping-pong loops between co-assigned agents. Structural fix: - In enqueueMentionedAgentTasks, suppress parent-mention inheritance when authorType == 'agent'. Explicit @mentions in the agent's own comment still work for deliberate handoffs. Defense-in-depth (prompt): - Strengthen per-turn prompt and AGENTS.md workflow instructions to explicitly forbid posting 'No reply needed' noise comments. Regression test: - TestAgentReplyDoesNotInheritParentMentions covers both the fix (agent reply does not re-trigger) and the positive control (member reply still inherits mentions). Also updates TestBuildPromptCommentTriggeredByAgent to match the new prompt wording. --- server/internal/daemon/daemon_test.go | 2 +- .../internal/daemon/execenv/runtime_config.go | 2 +- server/internal/daemon/prompt.go | 2 +- server/internal/handler/comment.go | 7 +- server/internal/handler/handler_test.go | 117 ++++++++++++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index 2495fdd853..33c26ce092 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -191,7 +191,7 @@ func TestBuildPromptCommentTriggeredByAgent(t *testing.T) { for _, want := range []string{ "Another agent (Atlas)", "do not @mention the other agent as a sign-off", - "silence is the preferred way", + "Silence is the preferred way", } { if !strings.Contains(prompt, want) { t.Fatalf("prompt missing %q\n---\n%s", want, prompt) diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 62ecc259c2..97a2524431 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -168,7 +168,7 @@ 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 ` 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. **Decide whether a reply is warranted.** If the triggering comment is an acknowledgment / thanks / sign-off from another agent and no concrete question or task is being asked of you, do NOT post a reply — just exit. Silence is a valid and preferred way to end agent-to-agent conversations.\n") + b.WriteString("4. **Decide whether a reply is warranted.** If the triggering comment is an acknowledgment / thanks / sign-off from another agent and no concrete question or task is being asked of you, do NOT post a reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is a valid and preferred way to end agent-to-agent conversations.\n") b.WriteString("5. If a reply IS warranted: do any requested work first, then **decide whether to include any `@mention` link.** The default is NO mention. Only mention when you are escalating to a human owner who is not yet involved, delegating a concrete new sub-task to another agent for the first time, or the user explicitly asked you to loop someone in. Never @mention the agent you are replying to as a thank-you or sign-off.\n") b.WriteString("6. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered to the user. ") b.WriteString(BuildCommentReplyInstructions(ctx.IssueID, ctx.TriggerCommentID)) diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index e56a9db37b..23447b2b45 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -49,7 +49,7 @@ func buildCommentPrompt(task Task) string { fmt.Fprintf(&b, "[NEW COMMENT] %s just left a new comment. Focus on THIS comment — do not confuse it with previous ones:\n\n", authorLabel) fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent) if task.TriggerAuthorType == "agent" { - b.WriteString("⚠️ The triggering comment was posted by another agent. Before replying, decide whether a reply is warranted at all. If that comment was an acknowledgment, thanks, or sign-off and no concrete question or task is being asked of you, do NOT reply — silence is the preferred way to end agent-to-agent threads. If you do reply, do not @mention the other agent as a sign-off (that re-triggers them and starts a loop).\n\n") + b.WriteString("⚠️ The triggering comment was posted by another agent. Before replying, decide whether a reply is warranted at all. If that comment was an acknowledgment, thanks, or sign-off and no concrete question or task is being asked of you, do NOT reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is the preferred way to end agent-to-agent threads. If you do reply, do not @mention the other agent as a sign-off (that re-triggers them and starts a loop).\n\n") } } fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then decide how to proceed.\n\n", task.IssueID) diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 22e57000b7..5eac1a4365 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -424,7 +424,12 @@ func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue // but only when the reply contains no mentions at all (a plain follow-up). // If the reply explicitly @mentions anyone (agents or members), the user // is making a deliberate choice about who to involve; don't auto-inherit. - if parentComment != nil && len(mentions) == 0 { + // + // CRITICAL: agent-authored replies must NOT inherit parent mentions. + // Otherwise an agent posting "No reply needed" in a thread whose root + // mentioned another agent would re-trigger that agent, creating a loop. + // Explicit @mentions in the agent's own comment still work for delegation. + if parentComment != nil && len(mentions) == 0 && authorType != "agent" { mentions = util.ParseMentions(parentComment.Content) } for _, m := range mentions { diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 57d37f75b2..60f114418e 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -1998,3 +1998,120 @@ func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) { t.Fatalf("DaemonRegister: expected workspace not found error, got %s", w.Body.String()) } } + +// TestAgentReplyDoesNotInheritParentMentions verifies that agent-authored +// replies do NOT inherit parent-comment mentions, preventing agent-to-agent +// re-trigger loops (e.g. "No reply needed" chains). Member-authored replies +// still inherit parent mentions as expected. +func TestAgentReplyDoesNotInheritParentMentions(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + ctx := context.Background() + + // Create two agents. + agentA := createHandlerTestAgent(t, "Loop Agent A", nil) + agentB := createHandlerTestAgent(t, "Loop Agent B", nil) + + // Create an unassigned issue so on_comment doesn't fire. + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Agent mention inheritance test", + "status": "todo", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var issue IssueResponse + json.NewDecoder(w.Body).Decode(&issue) + issueID := issue.ID + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) + }) + + // Helper: count queued tasks for a given agent on this issue. + countTasks := func(agentID string) int { + var n int + err := testPool.QueryRow(ctx, + `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`, + issueID, agentID, + ).Scan(&n) + if err != nil { + t.Fatalf("failed to count tasks: %v", err) + } + return n + } + + // Helper: cancel all tasks for an agent on this issue. + cancelTasks := func(agentID string) { + _, err := testPool.Exec(ctx, + `UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`, + issueID, agentID, + ) + if err != nil { + t.Fatalf("failed to cancel tasks: %v", err) + } + } + + postComment := func(issueID string, body map[string]any, headers map[string]string) *httptest.ResponseRecorder { + 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 + } + + // 1. Member posts top-level comment mentioning Agent B. + mentionB := fmt.Sprintf("[@Agent B](mention://agent/%s) please review", agentB) + w = postComment(issueID, map[string]any{"content": mentionB}, nil) + if w.Code != http.StatusCreated { + t.Fatalf("member mention comment: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var parentComment CommentResponse + json.NewDecoder(w.Body).Decode(&parentComment) + if countTasks(agentB) != 1 { + t.Fatalf("expected 1 task for Agent B after member mention, got %d", countTasks(agentB)) + } + + // 2. Cancel Agent B's task so it's free to be re-triggered. + cancelTasks(agentB) + if countTasks(agentB) != 0 { + t.Fatalf("expected 0 tasks for Agent B after cancel, got %d", countTasks(agentB)) + } + + // 3. Agent A posts a reply in the same thread with NO mentions. + // With the fix, this must NOT inherit the parent mention of Agent B. + w = postComment(issueID, map[string]any{ + "content": "No reply needed — just an acknowledgment.", + "parent_id": parentComment.ID, + }, map[string]string{"X-Agent-ID": agentA}) + if w.Code != http.StatusCreated { + t.Fatalf("agent A reply: expected 201, got %d: %s", w.Code, w.Body.String()) + } + if countTasks(agentB) != 0 { + t.Fatalf("expected 0 tasks for Agent B after agent reply (no parent inheritance), got %d", countTasks(agentB)) + } + + // 4. Cancel any stray tasks. + cancelTasks(agentB) + + // 5. Member posts a reply in the same thread with NO mentions. + // This SHOULD inherit the parent mention and re-trigger Agent B. + w = postComment(issueID, map[string]any{ + "content": "Thanks for the review.", + "parent_id": parentComment.ID, + }, nil) + if w.Code != http.StatusCreated { + t.Fatalf("member reply: expected 201, got %d: %s", w.Code, w.Body.String()) + } + if countTasks(agentB) != 1 { + t.Fatalf("expected 1 task for Agent B after member reply (parent inheritance allowed), got %d", countTasks(agentB)) + } +} From 541aaa974d37aab94bf205f60e173f31906284e0 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:21:39 +0800 Subject: [PATCH 25/38] fix(server): clarify silent-exit prompt and pin handoff contract (#1775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to #1765 review nits: - Tighten the per-turn prompt and AGENTS.md workflow instructions so that "exit with no output" only applies when the trigger is from another agent AND no actual work was produced this turn. If the agent did real work, the standard "post results as a comment" rule still applies — a result reply is not a noise comment. - Add TestAgentExplicitMentionStillTriggers as a positive control documenting the boundary the structural fix preserves: suppressing implicit parent-mention inheritance for agent authors does NOT block deliberate handoffs. An agent that explicitly @mentions another agent in its own content still enqueues a task for the mentioned agent and does not self-trigger. --- .../internal/daemon/execenv/runtime_config.go | 2 +- server/internal/daemon/prompt.go | 2 +- server/internal/handler/handler_test.go | 67 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 97a2524431..57c2422e4b 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -168,7 +168,7 @@ 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 ` 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. **Decide whether a reply is warranted.** If the triggering comment is an acknowledgment / thanks / sign-off from another agent and no concrete question or task is being asked of you, do NOT post a reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is a valid and preferred way to end agent-to-agent conversations.\n") + b.WriteString("4. **Decide whether a reply is warranted.** If you produced actual work this turn (investigated, fixed, answered a real question), post the result via step 6 — that is a normal reply, not a noise comment. If the triggering comment was a pure acknowledgment / thanks / sign-off from another agent AND you produced no work this turn, do NOT post a reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is a valid and preferred way to end agent-to-agent conversations.\n") b.WriteString("5. If a reply IS warranted: do any requested work first, then **decide whether to include any `@mention` link.** The default is NO mention. Only mention when you are escalating to a human owner who is not yet involved, delegating a concrete new sub-task to another agent for the first time, or the user explicitly asked you to loop someone in. Never @mention the agent you are replying to as a thank-you or sign-off.\n") b.WriteString("6. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered to the user. ") b.WriteString(BuildCommentReplyInstructions(ctx.IssueID, ctx.TriggerCommentID)) diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index 23447b2b45..24dc266fdf 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -49,7 +49,7 @@ func buildCommentPrompt(task Task) string { fmt.Fprintf(&b, "[NEW COMMENT] %s just left a new comment. Focus on THIS comment — do not confuse it with previous ones:\n\n", authorLabel) fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent) if task.TriggerAuthorType == "agent" { - b.WriteString("⚠️ The triggering comment was posted by another agent. Before replying, decide whether a reply is warranted at all. If that comment was an acknowledgment, thanks, or sign-off and no concrete question or task is being asked of you, do NOT reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is the preferred way to end agent-to-agent threads. If you do reply, do not @mention the other agent as a sign-off (that re-triggers them and starts a loop).\n\n") + b.WriteString("⚠️ The triggering comment was posted by another agent. Decide whether a reply is warranted. If you produced actual work this turn (investigated, fixed something, answered a real question), post the result as a normal reply — that is NOT a noise comment, and the standard rule that final results must be delivered via comment still applies. If the triggering comment was a pure acknowledgment, thanks, or sign-off AND you produced no work this turn, do NOT reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is the preferred way to end agent-to-agent threads. If you do reply, do not @mention the other agent as a sign-off (that re-triggers them and starts a loop).\n\n") } } fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then decide how to proceed.\n\n", task.IssueID) diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 60f114418e..967f7229fc 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -2115,3 +2115,70 @@ func TestAgentReplyDoesNotInheritParentMentions(t *testing.T) { t.Fatalf("expected 1 task for Agent B after member reply (parent inheritance allowed), got %d", countTasks(agentB)) } } + +// TestAgentExplicitMentionStillTriggers documents the boundary the structural +// fix preserves: suppressing implicit parent-mention inheritance for agent +// authors does NOT block deliberate handoffs. An agent that explicitly +// @mentions another agent in its own comment content still enqueues a task +// for that mentioned agent. +func TestAgentExplicitMentionStillTriggers(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + ctx := context.Background() + + agentA := createHandlerTestAgent(t, "Handoff Agent A", nil) + agentB := createHandlerTestAgent(t, "Handoff Agent B", nil) + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Agent explicit handoff test", + "status": "todo", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var issue IssueResponse + json.NewDecoder(w.Body).Decode(&issue) + issueID := issue.ID + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) + }) + + countTasks := func(agentID string) int { + var n int + err := testPool.QueryRow(ctx, + `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`, + issueID, agentID, + ).Scan(&n) + if err != nil { + t.Fatalf("failed to count tasks: %v", err) + } + return n + } + + // Agent A posts a top-level comment that explicitly @mentions Agent B — + // a deliberate handoff. This must enqueue a task for Agent B, and must + // not enqueue a self-trigger for Agent A. + explicitMention := fmt.Sprintf("[@Agent B](mention://agent/%s) please take it from here", agentB) + w = httptest.NewRecorder() + r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{ + "content": explicitMention, + }) + r = withURLParam(r, "id", issueID) + r.Header.Set("X-Agent-ID", agentA) + testHandler.CreateComment(w, r) + if w.Code != http.StatusCreated { + t.Fatalf("agent A handoff: expected 201, got %d: %s", w.Code, w.Body.String()) + } + if got := countTasks(agentB); got != 1 { + t.Fatalf("expected 1 task for Agent B after explicit mention by Agent A, got %d", got) + } + if got := countTasks(agentA); got != 0 { + t.Fatalf("expected 0 tasks for Agent A (no self-trigger on own mention), got %d", got) + } +} From 9db91e89f55952af14cbafe03d40a8575da2115c Mon Sep 17 00:00:00 2001 From: devv-eve Date: Tue, 28 Apr 2026 16:07:24 +0800 Subject: [PATCH 26/38] feat: add daemon websocket task wakeups (#1772) * feat: add daemon websocket task wakeups * feat: fan out daemon wakeups across nodes * fix: dedupe daemon wakeup loopback events * fix: lengthen daemon polling fallback interval --------- Co-authored-by: Eve --- server/cmd/server/health_realtime.go | 5 +- server/cmd/server/main.go | 18 +- server/cmd/server/router.go | 15 +- server/internal/daemon/config.go | 2 +- server/internal/daemon/daemon.go | 35 +- server/internal/daemon/helpers.go | 18 + server/internal/daemon/wakeup.go | 188 ++++++++++ server/internal/daemon/wakeup_test.go | 43 +++ server/internal/daemonws/hub.go | 349 ++++++++++++++++++ server/internal/daemonws/hub_test.go | 200 ++++++++++ server/internal/daemonws/metrics.go | 44 +++ server/internal/daemonws/notifier.go | 49 +++ server/internal/handler/daemon_ws.go | 66 ++++ server/internal/handler/handler.go | 12 +- server/internal/metrics/daemonws.go | 70 ++++ server/internal/metrics/registry.go | 5 + server/internal/realtime/broadcaster.go | 8 + server/internal/realtime/redis_relay.go | 14 +- server/internal/realtime/relay_lifecycle.go | 12 + .../internal/realtime/relay_lifecycle_test.go | 17 + .../internal/realtime/sharded_stream_relay.go | 8 +- server/internal/service/task.go | 24 +- server/pkg/protocol/events.go | 13 +- server/pkg/protocol/messages.go | 15 +- 24 files changed, 1202 insertions(+), 28 deletions(-) create mode 100644 server/internal/daemon/wakeup.go create mode 100644 server/internal/daemon/wakeup_test.go create mode 100644 server/internal/daemonws/hub.go create mode 100644 server/internal/daemonws/hub_test.go create mode 100644 server/internal/daemonws/metrics.go create mode 100644 server/internal/daemonws/notifier.go create mode 100644 server/internal/handler/daemon_ws.go create mode 100644 server/internal/metrics/daemonws.go diff --git a/server/cmd/server/health_realtime.go b/server/cmd/server/health_realtime.go index 2a8748ae7f..00ae3a0841 100644 --- a/server/cmd/server/health_realtime.go +++ b/server/cmd/server/health_realtime.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "github.com/multica-ai/multica/server/internal/daemonws" "github.com/multica-ai/multica/server/internal/realtime" ) @@ -47,7 +48,9 @@ func realtimeMetricsHandler(token string) http.HandlerFunc { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(realtime.M.Snapshot()) + snapshot := realtime.M.Snapshot() + snapshot["daemonws"] = daemonws.M.Snapshot() + _ = json.NewEncoder(w).Encode(snapshot) } } diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index acbb39d73a..aa990e13a9 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "time" "github.com/multica-ai/multica/server/internal/analytics" + "github.com/multica-ai/multica/server/internal/daemonws" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/logger" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" @@ -161,6 +162,8 @@ func main() { bus := events.New() hub := realtime.NewHub() go hub.Run() + daemonHub := daemonws.NewHub() + var daemonWakeup service.TaskWakeupNotifier = daemonHub // MUL-1138: when REDIS_URL is set, route fanout through a Redis relay so // multiple API nodes can deliver each other's events. Without it the hub @@ -204,15 +207,21 @@ func main() { case "legacy": relayReadRedis = newNamedRedisClient(opts, "realtime-read") relay = realtime.NewRedisRelayWithClients(hub, relayWriteRedis, relayReadRedis) + slog.Info("daemon websocket wakeup: Redis fanout disabled in legacy realtime relay mode") case "dual": shardedReadRedis = newNamedRedisClient(opts, "realtime-read-sharded") legacyReadRedis = newNamedRedisClient(opts, "realtime-read-legacy") sharded := realtime.NewShardedStreamRelay(hub, relayWriteRedis, shardedReadRedis, relayConfig) + sharded.SetDaemonRuntimeDeliverer(daemonHub) legacy := realtime.NewRedisRelayWithClients(hub, relayWriteRedis, legacyReadRedis) relay = realtime.NewMirroredRelay(sharded, legacy) + daemonWakeup = daemonws.NewRelayNotifier(daemonHub, sharded) default: relayReadRedis = newNamedRedisClient(opts, "realtime-read") - relay = realtime.NewShardedStreamRelay(hub, relayWriteRedis, relayReadRedis, relayConfig) + sharded := realtime.NewShardedStreamRelay(hub, relayWriteRedis, relayReadRedis, relayConfig) + sharded.SetDaemonRuntimeDeliverer(daemonHub) + relay = sharded + daemonWakeup = daemonws.NewRelayNotifier(daemonHub, sharded) } relay.Start(relayCtx) broadcaster = realtime.NewDualWriteBroadcaster(hub, relay) @@ -253,6 +262,7 @@ func main() { metricsRegistry := obsmetrics.NewRegistry(obsmetrics.RegistryOptions{ Pool: pool, Realtime: realtime.M, + DaemonWS: daemonws.M, Version: version, Commit: commit, }) @@ -267,7 +277,9 @@ func main() { } r := NewRouterWithOptions(pool, hub, bus, analyticsClient, storeRedis, RouterOptions{ - HTTPMetrics: httpMetrics, + HTTPMetrics: httpMetrics, + DaemonHub: daemonHub, + DaemonWakeup: daemonWakeup, }) srv := &http.Server{ @@ -278,7 +290,7 @@ 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, pool, hub, bus, daemonWakeup) autopilotSvc := service.NewAutopilotService(queries, pool, bus, taskSvc) registerAutopilotListeners(bus, autopilotSvc) diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index a948501d94..c60e5acf20 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -15,6 +15,7 @@ import ( "github.com/multica-ai/multica/server/internal/analytics" "github.com/multica-ai/multica/server/internal/auth" + "github.com/multica-ai/multica/server/internal/daemonws" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/handler" obsmetrics "github.com/multica-ai/multica/server/internal/metrics" @@ -67,12 +68,18 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analytics } type RouterOptions struct { - HTTPMetrics *obsmetrics.HTTPMetrics + HTTPMetrics *obsmetrics.HTTPMetrics + DaemonHub *daemonws.Hub + DaemonWakeup service.TaskWakeupNotifier } func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus, analyticsClient analytics.Client, rdb *redis.Client, opts RouterOptions) chi.Router { queries := db.New(pool) emailSvc := service.NewEmailService() + daemonHub := opts.DaemonHub + if daemonHub == nil { + daemonHub = daemonws.NewHub() + } // Initialize storage with S3 as primary, fallback to local var store storage.Storage @@ -93,7 +100,10 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus 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, analyticsClient, signupConfig, daemonHub) + if opts.DaemonWakeup != nil { + h.TaskService.Wakeup = opts.DaemonWakeup + } if rdb != nil { h.LocalSkillListStore = handler.NewRedisLocalSkillListStore(rdb) h.LocalSkillImportStore = handler.NewRedisLocalSkillImportStore(rdb) @@ -178,6 +188,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Post("/register", h.DaemonRegister) r.Post("/deregister", h.DaemonDeregister) r.Post("/heartbeat", h.DaemonHeartbeat) + r.Get("/ws", h.DaemonWebSocket) r.Get("/workspaces/{workspaceId}/repos", h.GetDaemonWorkspaceRepos) r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index e52ebf386c..10c306a606 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -12,7 +12,7 @@ import ( const ( DefaultServerURL = "ws://localhost:8080/ws" - DefaultPollInterval = 3 * time.Second + DefaultPollInterval = 30 * time.Second DefaultHeartbeatInterval = 15 * time.Second DefaultAgentTimeout = 2 * time.Hour DefaultCodexSemanticInactivityTimeout = 10 * time.Minute diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 0d5460a4c1..bbdc78ee15 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -45,6 +45,7 @@ type Daemon struct { workspaces map[string]*workspaceState runtimeIndex map[string]Runtime // runtimeID -> Runtime for provider lookups reloading sync.Mutex // prevents concurrent workspace syncs + runtimeSetCh chan struct{} // notifies the WS wakeup loop to reconnect with a new runtime set versionsMu sync.RWMutex // guards agentVersions agentVersions map[string]string // provider -> detected CLI version (set during registration) @@ -69,6 +70,7 @@ func New(cfg Config, logger *slog.Logger) *Daemon { logger: logger, workspaces: make(map[string]*workspaceState), runtimeIndex: make(map[string]Runtime), + runtimeSetCh: make(chan struct{}, 1), agentVersions: make(map[string]string), } } @@ -89,6 +91,23 @@ func (d *Daemon) agentVersion(provider string) string { return d.agentVersions[provider] } +func (d *Daemon) notifyRuntimeSetChanged() { + select { + case d.runtimeSetCh <- struct{}{}: + default: + } +} + +func (d *Daemon) drainRuntimeSetChanged() { + for { + select { + case <-d.runtimeSetCh: + default: + return + } + } +} + // Run starts the daemon: resolves auth, registers runtimes, then polls for tasks. func (d *Daemon) Run(ctx context.Context) error { // Wrap context so handleUpdate can cancel the daemon for restart. @@ -132,10 +151,13 @@ func (d *Daemon) Run(ctx context.Context) error { // Start workspace sync loop to discover newly created workspaces. go d.workspaceSyncLoop(ctx) + taskWakeups := make(chan struct{}, 1) + d.drainRuntimeSetChanged() + go d.taskWakeupLoop(ctx, taskWakeups) go d.heartbeatLoop(ctx) go d.gcLoop(ctx) go d.serveHealth(ctx, healthLn, time.Now()) - return d.pollLoop(ctx) + return d.pollLoop(ctx, taskWakeups) } // RestartBinary returns the path to the new binary if the daemon needs to restart @@ -422,6 +444,7 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) error { d.mu.Unlock() var registered int + var removed int for id, name := range apiIDs { if currentIDs[id] { continue // important: never replace existing workspaceState; ensureRepoReady holds ws.repoRefreshMu from the original pointer @@ -473,8 +496,12 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) error { delete(d.workspaces, id) d.mu.Unlock() d.logger.Info("stopped watching workspace", "workspace_id", id) + removed++ } } + if registered > 0 || removed > 0 { + d.notifyRuntimeSetChanged() + } if len(d.allRuntimeIDs()) == 0 && registered == 0 && len(workspaces) > 0 { return fmt.Errorf("failed to register runtimes for any of the %d workspace(s)", len(workspaces)) @@ -799,7 +826,7 @@ func (d *Daemon) triggerRestart() { } } -func (d *Daemon) pollLoop(ctx context.Context) error { +func (d *Daemon) pollLoop(ctx context.Context, taskWakeups <-chan struct{}) error { sem := make(chan struct{}, d.cfg.MaxConcurrentTasks) var wg sync.WaitGroup @@ -822,7 +849,7 @@ func (d *Daemon) pollLoop(ctx context.Context) error { runtimeIDs := d.allRuntimeIDs() if len(runtimeIDs) == 0 { - if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { + if err := sleepWithContextOrWakeup(ctx, d.cfg.PollInterval, taskWakeups); err != nil { wg.Wait() return err } @@ -878,7 +905,7 @@ func (d *Daemon) pollLoop(ctx context.Context) error { d.logger.Debug("poll: no tasks", "runtimes", runtimeIDs, "cycle", pollCount) } pollOffset = (pollOffset + 1) % n - if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil { + if err := sleepWithContextOrWakeup(ctx, d.cfg.PollInterval, taskWakeups); err != nil { wg.Wait() return err } diff --git a/server/internal/daemon/helpers.go b/server/internal/daemon/helpers.go index 2e93ed7ed1..4aa81781a6 100644 --- a/server/internal/daemon/helpers.go +++ b/server/internal/daemon/helpers.go @@ -78,3 +78,21 @@ func sleepWithContext(ctx context.Context, d time.Duration) error { return nil } } + +func sleepWithContextOrWakeup(ctx context.Context, d time.Duration, wakeups <-chan struct{}) error { + if wakeups == nil { + return sleepWithContext(ctx, d) + } + + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-wakeups: + return nil + case <-timer.C: + return nil + } +} diff --git a/server/internal/daemon/wakeup.go b/server/internal/daemon/wakeup.go new file mode 100644 index 0000000000..9ab2c58fb1 --- /dev/null +++ b/server/internal/daemon/wakeup.go @@ -0,0 +1,188 @@ +package daemon + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/rand" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/multica-ai/multica/server/pkg/protocol" +) + +var errRuntimeSetChanged = errors.New("runtime set changed") + +func (d *Daemon) taskWakeupLoop(ctx context.Context, taskWakeups chan<- struct{}) { + backoff := time.Second + + for { + runtimeIDs := d.allRuntimeIDs() + if len(runtimeIDs) == 0 { + if err := sleepWithContextOrRuntimeChange(ctx, 5*time.Second, d.runtimeSetCh); err != nil { + return + } + continue + } + + err := d.runTaskWakeupConnection(ctx, runtimeIDs, taskWakeups) + if ctx.Err() != nil { + return + } + if errors.Is(err, errRuntimeSetChanged) { + backoff = time.Second + continue + } + if err != nil { + d.logger.Debug("task wakeup websocket unavailable; polling fallback remains active", "error", err, "retry_in", backoff) + } + + if err := sleepWithContextOrRuntimeChange(ctx, jitterDuration(backoff), d.runtimeSetCh); err != nil { + return + } + if backoff < 30*time.Second { + backoff *= 2 + if backoff > 30*time.Second { + backoff = 30 * time.Second + } + } + } +} + +func jitterDuration(d time.Duration) time.Duration { + if d <= 0 { + return d + } + spread := d / 5 + if spread <= 0 { + return d + } + delta := time.Duration(rand.Int63n(int64(spread)*2+1)) - spread + return d + delta +} + +func (d *Daemon) runTaskWakeupConnection(ctx context.Context, runtimeIDs []string, taskWakeups chan<- struct{}) error { + wsURL, err := taskWakeupURL(d.cfg.ServerBaseURL, runtimeIDs) + if err != nil { + return err + } + + headers := http.Header{} + if token := d.client.Token(); token != "" { + headers.Set("Authorization", "Bearer "+token) + } + if d.client.platform != "" { + headers.Set("X-Client-Platform", d.client.platform) + } + if d.client.version != "" { + headers.Set("X-Client-Version", d.client.version) + } + if d.client.os != "" { + headers.Set("X-Client-OS", d.client.os) + } + + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + conn, _, err := dialer.DialContext(ctx, wsURL, headers) + if err != nil { + return err + } + defer conn.Close() + + d.logger.Info("task wakeup websocket connected", "runtimes", len(runtimeIDs)) + signalTaskWakeup(taskWakeups) + + errCh := make(chan error, 1) + go func() { + errCh <- d.readTaskWakeupMessages(conn, taskWakeups) + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-d.runtimeSetCh: + return errRuntimeSetChanged + case err := <-errCh: + return err + } +} + +func (d *Daemon) readTaskWakeupMessages(conn *websocket.Conn, taskWakeups chan<- struct{}) error { + conn.SetReadLimit(64 * 1024) + for { + _, raw, err := conn.ReadMessage() + if err != nil { + return err + } + var msg protocol.Message + if err := json.Unmarshal(raw, &msg); err != nil { + d.logger.Debug("task wakeup websocket invalid message", "error", err) + continue + } + if msg.Type != protocol.EventDaemonTaskAvailable { + continue + } + var payload protocol.TaskAvailablePayload + if len(msg.Payload) > 0 { + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + d.logger.Debug("task wakeup websocket invalid payload", "error", err) + continue + } + } + if payload.RuntimeID != "" { + d.logger.Debug("task wakeup received", "runtime_id", payload.RuntimeID, "task_id", payload.TaskID) + } + signalTaskWakeup(taskWakeups) + } +} + +func signalTaskWakeup(taskWakeups chan<- struct{}) { + select { + case taskWakeups <- struct{}{}: + default: + } +} + +func taskWakeupURL(baseURL string, runtimeIDs []string) (string, error) { + u, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return "", fmt.Errorf("invalid daemon server URL: %w", err) + } + switch u.Scheme { + case "http": + u.Scheme = "ws" + case "https": + u.Scheme = "wss" + case "ws", "wss": + default: + return "", fmt.Errorf("daemon server URL must use http, https, ws, or wss") + } + + u.Path = strings.TrimRight(u.Path, "/") + "/api/daemon/ws" + u.RawPath = "" + q := u.Query() + ids := append([]string(nil), runtimeIDs...) + sort.Strings(ids) + q.Set("runtime_ids", strings.Join(ids, ",")) + u.RawQuery = q.Encode() + u.Fragment = "" + return u.String(), nil +} + +func sleepWithContextOrRuntimeChange(ctx context.Context, d time.Duration, runtimeSetCh <-chan struct{}) error { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-runtimeSetCh: + return nil + case <-timer.C: + return nil + } +} diff --git a/server/internal/daemon/wakeup_test.go b/server/internal/daemon/wakeup_test.go new file mode 100644 index 0000000000..ce9960fc0b --- /dev/null +++ b/server/internal/daemon/wakeup_test.go @@ -0,0 +1,43 @@ +package daemon + +import "testing" + +func TestTaskWakeupURL(t *testing.T) { + tests := []struct { + name string + baseURL string + runtimeIDs []string + want string + }{ + { + name: "http base", + baseURL: "http://localhost:8080", + runtimeIDs: []string{"runtime-b", "runtime-a"}, + want: "ws://localhost:8080/api/daemon/ws?runtime_ids=runtime-a%2Cruntime-b", + }, + { + name: "https base", + baseURL: "https://api.example.com", + runtimeIDs: []string{"runtime-1"}, + want: "wss://api.example.com/api/daemon/ws?runtime_ids=runtime-1", + }, + { + name: "base path", + baseURL: "https://api.example.com/multica", + runtimeIDs: []string{"runtime-1"}, + want: "wss://api.example.com/multica/api/daemon/ws?runtime_ids=runtime-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := taskWakeupURL(tt.baseURL, tt.runtimeIDs) + if err != nil { + t.Fatalf("taskWakeupURL: %v", err) + } + if got != tt.want { + t.Fatalf("taskWakeupURL() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/server/internal/daemonws/hub.go b/server/internal/daemonws/hub.go new file mode 100644 index 0000000000..eeecdacc4b --- /dev/null +++ b/server/internal/daemonws/hub.go @@ -0,0 +1,349 @@ +package daemonws + +import ( + "encoding/json" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/multica-ai/multica/server/pkg/protocol" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 +) + +// ClientIdentity captures the already-authenticated daemon connection scope. +type ClientIdentity struct { + DaemonID string + UserID string + WorkspaceID string + RuntimeIDs []string + ClientVersion string +} + +type client struct { + hub *Hub + conn *websocket.Conn + send chan []byte + identity ClientIdentity + runtimes map[string]struct{} + + dedupMu sync.Mutex + seenIDs map[string]struct{} + seenList []string +} + +const eventDedupCapacity = 128 + +// markSeen records eventID as already delivered to this client. Empty event IDs +// disable dedup and are always delivered. +func (c *client) markSeen(eventID string) bool { + if eventID == "" { + return true + } + c.dedupMu.Lock() + defer c.dedupMu.Unlock() + if c.seenIDs == nil { + c.seenIDs = make(map[string]struct{}, eventDedupCapacity) + } + if _, ok := c.seenIDs[eventID]; ok { + return false + } + c.seenIDs[eventID] = struct{}{} + c.seenList = append(c.seenList, eventID) + if len(c.seenList) > eventDedupCapacity { + drop := c.seenList[0] + c.seenList = c.seenList[1:] + delete(c.seenIDs, drop) + } + return true +} + +// Hub keeps daemon WebSocket connections indexed by runtime ID. Messages are +// best-effort wakeup hints; the daemon still uses HTTP claim for correctness. +type Hub struct { + upgrader websocket.Upgrader + + mu sync.RWMutex + clients map[*client]bool + byRuntime map[string]map[*client]bool +} + +func NewHub() *Hub { + return &Hub{ + upgrader: websocket.Upgrader{ + // Daemon clients authenticate with Authorization headers before the + // upgrade. Browsers cannot set those headers through the native WS API, + // and DaemonAuth does not accept cookies, so cookie-based CSWSH does + // not apply to this endpoint. Re-evaluate this if DaemonAuth ever + // grows cookie fallback. + CheckOrigin: func(r *http.Request) bool { return true }, + }, + clients: make(map[*client]bool), + byRuntime: make(map[string]map[*client]bool), + } +} + +func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request, identity ClientIdentity) { + if len(identity.RuntimeIDs) == 0 { + http.Error(w, `{"error":"runtime_ids required"}`, http.StatusBadRequest) + return + } + + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + slog.Error("daemon websocket upgrade failed", "error", err) + return + } + + runtimes := make(map[string]struct{}, len(identity.RuntimeIDs)) + for _, runtimeID := range identity.RuntimeIDs { + if runtimeID != "" { + runtimes[runtimeID] = struct{}{} + } + } + if len(runtimes) == 0 { + conn.WriteMessage(websocket.TextMessage, []byte(`{"error":"runtime_ids required"}`)) + conn.Close() + return + } + + c := &client{ + hub: h, + conn: conn, + send: make(chan []byte, 16), + identity: identity, + runtimes: runtimes, + } + h.register(c) + + go c.writePump() + go c.readPump() +} + +// NotifyTaskAvailable sends a best-effort wakeup to daemons watching runtimeID. +func (h *Hub) NotifyTaskAvailable(runtimeID, taskID string) { + h.notifyTaskAvailable(runtimeID, taskID, "") +} + +func (h *Hub) notifyTaskAvailable(runtimeID, taskID, eventID string) { + if h == nil || runtimeID == "" { + return + } + data, err := taskAvailableFrame(runtimeID, taskID) + if err != nil { + return + } + delivered, deduped := h.notifyFrame(runtimeID, data, eventID) + if delivered { + M.WakeupDeliveredHit.Add(1) + } else if !deduped { + M.WakeupDeliveredMiss.Add(1) + } +} + +func (h *Hub) DeliverDaemonRuntime(scopeID string, frame []byte, eventID string) { + if h == nil { + return + } + M.WakeupReceivedTotal.Add(1) + var msg protocol.Message + if err := json.Unmarshal(frame, &msg); err != nil { + slog.Debug("daemon websocket relay: invalid frame", "error", err, "scope_id", scopeID, "event_id", eventID) + M.WakeupDeliveredMiss.Add(1) + return + } + if msg.Type != protocol.EventDaemonTaskAvailable { + M.WakeupDeliveredMiss.Add(1) + return + } + var payload protocol.TaskAvailablePayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.RuntimeID == "" { + slog.Debug("daemon websocket relay: invalid task_available payload", "error", err, "scope_id", scopeID, "event_id", eventID) + M.WakeupDeliveredMiss.Add(1) + return + } + delivered, deduped := h.notifyFrame(payload.RuntimeID, frame, eventID) + if delivered { + M.WakeupDeliveredHit.Add(1) + } else if !deduped { + M.WakeupDeliveredMiss.Add(1) + } +} + +func (h *Hub) notifyFrame(runtimeID string, data []byte, eventID string) (delivered bool, deduped bool) { + h.mu.RLock() + clients := h.byRuntime[runtimeID] + slow := make([]*client, 0) + for c := range clients { + if !c.markSeen(eventID) { + deduped = true + continue + } + select { + case c.send <- data: + delivered = true + default: + slow = append(slow, c) + } + } + h.mu.RUnlock() + + for _, c := range slow { + h.unregister(c) + c.conn.Close() + } + if len(slow) > 0 { + M.SlowEvictionsTotal.Add(int64(len(slow))) + } + return delivered, deduped +} + +func taskAvailableFrame(runtimeID, taskID string) ([]byte, error) { + return json.Marshal(protocol.Message{ + Type: protocol.EventDaemonTaskAvailable, + Payload: mustMarshalRaw(protocol.TaskAvailablePayload{ + RuntimeID: runtimeID, + TaskID: taskID, + }), + }) +} + +func mustMarshalRaw(v any) json.RawMessage { + data, err := json.Marshal(v) + if err != nil { + return nil + } + return data +} + +func (h *Hub) RuntimeConnectionCount(runtimeID string) int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.byRuntime[runtimeID]) +} + +func (h *Hub) register(c *client) { + h.mu.Lock() + h.clients[c] = true + for runtimeID := range c.runtimes { + conns := h.byRuntime[runtimeID] + if conns == nil { + conns = make(map[*client]bool) + h.byRuntime[runtimeID] = conns + } + conns[c] = true + } + total := len(h.clients) + h.mu.Unlock() + + M.ConnectsTotal.Add(1) + M.ActiveConnections.Add(1) + slog.Info("daemon websocket connected", + "daemon_id", c.identity.DaemonID, + "user_id", c.identity.UserID, + "workspace_id", c.identity.WorkspaceID, + "runtimes", len(c.runtimes), + "client_version", c.identity.ClientVersion, + "total_clients", total, + ) +} + +func (h *Hub) unregister(c *client) { + h.mu.Lock() + if !h.clients[c] { + h.mu.Unlock() + return + } + delete(h.clients, c) + for runtimeID := range c.runtimes { + if conns := h.byRuntime[runtimeID]; conns != nil { + delete(conns, c) + if len(conns) == 0 { + delete(h.byRuntime, runtimeID) + } + } + } + close(c.send) + total := len(h.clients) + h.mu.Unlock() + + M.DisconnectsTotal.Add(1) + M.ActiveConnections.Add(-1) + slog.Info("daemon websocket disconnected", + "daemon_id", c.identity.DaemonID, + "user_id", c.identity.UserID, + "workspace_id", c.identity.WorkspaceID, + "runtimes", len(c.runtimes), + "total_clients", total, + ) +} + +func (c *client) readPump() { + defer func() { + c.hub.unregister(c) + c.conn.Close() + }() + + c.conn.SetReadLimit(4096) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, raw, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + slog.Debug("daemon websocket read error", "error", err, "daemon_id", c.identity.DaemonID) + } + return + } + c.handleFrame(raw) + } +} + +func (c *client) handleFrame(raw []byte) { + var msg protocol.Message + if err := json.Unmarshal(raw, &msg); err != nil { + slog.Debug("daemon websocket invalid frame", "error", err, "daemon_id", c.identity.DaemonID) + return + } + // The phase-one daemon channel is server-push only. Inbound frames are + // drained so control frames and close handling work, but app messages are + // intentionally ignored for forward compatibility. +} + +func (c *client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { + slog.Debug("daemon websocket write error", "error", err, "daemon_id", c.identity.DaemonID) + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} diff --git a/server/internal/daemonws/hub_test.go b/server/internal/daemonws/hub_test.go new file mode 100644 index 0000000000..c522c87232 --- /dev/null +++ b/server/internal/daemonws/hub_test.go @@ -0,0 +1,200 @@ +package daemonws + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/multica-ai/multica/server/internal/realtime" + "github.com/multica-ai/multica/server/pkg/protocol" +) + +func TestNotifyTaskAvailable(t *testing.T) { + M.Reset() + defer M.Reset() + + hub := NewHub() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hub.HandleWebSocket(w, r, ClientIdentity{RuntimeIDs: []string{"runtime-1"}}) + })) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + deadline := time.Now().Add(time.Second) + for hub.RuntimeConnectionCount("runtime-1") == 0 { + if time.Now().After(deadline) { + t.Fatal("runtime connection was not registered") + } + time.Sleep(10 * time.Millisecond) + } + + hub.NotifyTaskAvailable("runtime-1", "task-1") + + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + _, raw, err := conn.ReadMessage() + if err != nil { + t.Fatalf("ReadMessage: %v", err) + } + + var msg protocol.Message + if err := json.Unmarshal(raw, &msg); err != nil { + t.Fatalf("unmarshal message: %v", err) + } + if msg.Type != protocol.EventDaemonTaskAvailable { + t.Fatalf("message type = %q, want %q", msg.Type, protocol.EventDaemonTaskAvailable) + } + + var payload protocol.TaskAvailablePayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload.RuntimeID != "runtime-1" || payload.TaskID != "task-1" { + t.Fatalf("payload = %+v, want runtime/task IDs", payload) + } +} + +func TestRelayNotifierPublishesDaemonRuntimeScope(t *testing.T) { + M.Reset() + defer M.Reset() + + relay := &recordingRelayPublisher{} + notifier := NewRelayNotifier(nil, relay) + + notifier.NotifyTaskAvailable("runtime-1", "task-1") + + if relay.scopeType != realtime.ScopeDaemonRuntime { + t.Fatalf("scopeType = %q, want %q", relay.scopeType, realtime.ScopeDaemonRuntime) + } + if relay.scopeID != "task-1" { + t.Fatalf("scopeID = %q, want task_id shard key", relay.scopeID) + } + if relay.eventID == "" { + t.Fatal("expected event id") + } + if M.WakeupPublishedTotal.Load() != 1 { + t.Fatalf("published metric = %d, want 1", M.WakeupPublishedTotal.Load()) + } + + var msg protocol.Message + if err := json.Unmarshal(relay.frame, &msg); err != nil { + t.Fatalf("unmarshal frame: %v", err) + } + if msg.Type != protocol.EventDaemonTaskAvailable { + t.Fatalf("message type = %q, want %q", msg.Type, protocol.EventDaemonTaskAvailable) + } + var payload protocol.TaskAvailablePayload + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload.RuntimeID != "runtime-1" || payload.TaskID != "task-1" { + t.Fatalf("payload = %+v, want runtime/task IDs", payload) + } +} + +func TestRelayNotifierDedupsLocalRedisLoopback(t *testing.T) { + M.Reset() + defer M.Reset() + + hub := NewHub() + client := attachDaemonTestClient(hub, "runtime-1") + relay := &localFirstDaemonRelayPublisher{t: t, client: client} + notifier := NewRelayNotifier(hub, relay) + + notifier.NotifyTaskAvailable("runtime-1", "task-1") + + if !relay.called { + t.Fatal("expected relay publish to be invoked") + } + if relay.eventID == "" { + t.Fatal("expected event id") + } + if M.WakeupDeliveredHit.Load() != 1 { + t.Fatalf("delivered hit metric = %d, want 1", M.WakeupDeliveredHit.Load()) + } + + hub.DeliverDaemonRuntime(relay.scopeID, relay.frame, relay.eventID) + + select { + case duplicate := <-client.send: + t.Fatalf("expected redis loopback to be deduped, got duplicate %s", duplicate) + case <-time.After(20 * time.Millisecond): + } + if M.WakeupDeliveredHit.Load() != 1 { + t.Fatalf("delivered hit metric after loopback = %d, want 1", M.WakeupDeliveredHit.Load()) + } + if M.WakeupDeliveredMiss.Load() != 0 { + t.Fatalf("delivered miss metric after dedup = %d, want 0", M.WakeupDeliveredMiss.Load()) + } +} + +func attachDaemonTestClient(hub *Hub, runtimeID string) *client { + c := &client{ + send: make(chan []byte, 2), + runtimes: map[string]struct{}{runtimeID: {}}, + } + + hub.mu.Lock() + hub.clients[c] = true + hub.byRuntime[runtimeID] = map[*client]bool{c: true} + hub.mu.Unlock() + + return c +} + +type recordingRelayPublisher struct { + scopeType string + scopeID string + exclude string + frame []byte + eventID string +} + +func (r *recordingRelayPublisher) PublishWithID(scopeType, scopeID, exclude string, frame []byte, id string) error { + r.scopeType = scopeType + r.scopeID = scopeID + r.exclude = exclude + r.frame = append([]byte(nil), frame...) + r.eventID = id + return nil +} + +type localFirstDaemonRelayPublisher struct { + t *testing.T + client *client + + called bool + scopeType string + scopeID string + exclude string + frame []byte + eventID string + localFrame []byte +} + +func (p *localFirstDaemonRelayPublisher) PublishWithID(scopeType, scopeID, exclude string, frame []byte, id string) error { + p.called = true + p.scopeType = scopeType + p.scopeID = scopeID + p.exclude = exclude + p.frame = append([]byte(nil), frame...) + p.eventID = id + + select { + case p.localFrame = <-p.client.send: + default: + p.t.Fatal("expected local fanout to happen before relay publish") + } + return nil +} diff --git a/server/internal/daemonws/metrics.go b/server/internal/daemonws/metrics.go new file mode 100644 index 0000000000..63bd2ce770 --- /dev/null +++ b/server/internal/daemonws/metrics.go @@ -0,0 +1,44 @@ +package daemonws + +import "sync/atomic" + +type Metrics struct { + ConnectsTotal atomic.Int64 + DisconnectsTotal atomic.Int64 + ActiveConnections atomic.Int64 + SlowEvictionsTotal atomic.Int64 + + WakeupPublishedTotal atomic.Int64 + WakeupPublishErrors atomic.Int64 + WakeupReceivedTotal atomic.Int64 + WakeupDeliveredHit atomic.Int64 + WakeupDeliveredMiss atomic.Int64 +} + +var M = &Metrics{} + +func (m *Metrics) Snapshot() map[string]any { + return map[string]any{ + "connects_total": m.ConnectsTotal.Load(), + "disconnects_total": m.DisconnectsTotal.Load(), + "active_connections": m.ActiveConnections.Load(), + "slow_evictions_total": m.SlowEvictionsTotal.Load(), + "wakeup_published_total": m.WakeupPublishedTotal.Load(), + "wakeup_publish_errors": m.WakeupPublishErrors.Load(), + "wakeup_received_total": m.WakeupReceivedTotal.Load(), + "wakeup_delivered_hit_total": m.WakeupDeliveredHit.Load(), + "wakeup_delivered_miss_total": m.WakeupDeliveredMiss.Load(), + } +} + +func (m *Metrics) Reset() { + m.ConnectsTotal.Store(0) + m.DisconnectsTotal.Store(0) + m.ActiveConnections.Store(0) + m.SlowEvictionsTotal.Store(0) + m.WakeupPublishedTotal.Store(0) + m.WakeupPublishErrors.Store(0) + m.WakeupReceivedTotal.Store(0) + m.WakeupDeliveredHit.Store(0) + m.WakeupDeliveredMiss.Store(0) +} diff --git a/server/internal/daemonws/notifier.go b/server/internal/daemonws/notifier.go new file mode 100644 index 0000000000..c1d1f51cdb --- /dev/null +++ b/server/internal/daemonws/notifier.go @@ -0,0 +1,49 @@ +package daemonws + +import ( + "log/slog" + + "github.com/oklog/ulid/v2" + + "github.com/multica-ai/multica/server/internal/realtime" +) + +// RelayNotifier sends task wakeups to the local daemon hub and, when Redis is +// configured, publishes the same wakeup through the shared realtime relay so +// every API node can attempt local delivery. +type RelayNotifier struct { + local *Hub + relay realtime.RelayPublisher +} + +func NewRelayNotifier(local *Hub, relay realtime.RelayPublisher) *RelayNotifier { + return &RelayNotifier{local: local, relay: relay} +} + +func (n *RelayNotifier) NotifyTaskAvailable(runtimeID, taskID string) { + if runtimeID == "" { + return + } + eventID := ulid.Make().String() + if n.local != nil { + n.local.notifyTaskAvailable(runtimeID, taskID, eventID) + } + if n.relay == nil { + return + } + frame, err := taskAvailableFrame(runtimeID, taskID) + if err != nil { + M.WakeupPublishErrors.Add(1) + return + } + shardKey := taskID + if shardKey == "" { + shardKey = eventID + } + if err := n.relay.PublishWithID(realtime.ScopeDaemonRuntime, shardKey, "", frame, eventID); err != nil { + M.WakeupPublishErrors.Add(1) + slog.Warn("daemon websocket wakeup publish failed", "error", err, "runtime_id", runtimeID, "task_id", taskID) + return + } + M.WakeupPublishedTotal.Add(1) +} diff --git a/server/internal/handler/daemon_ws.go b/server/internal/handler/daemon_ws.go new file mode 100644 index 0000000000..869e1a13d0 --- /dev/null +++ b/server/internal/handler/daemon_ws.go @@ -0,0 +1,66 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/multica-ai/multica/server/internal/daemonws" + "github.com/multica-ai/multica/server/internal/middleware" +) + +func (h *Handler) DaemonWebSocket(w http.ResponseWriter, r *http.Request) { + if h.DaemonHub == nil { + writeError(w, http.StatusServiceUnavailable, "daemon websocket unavailable") + return + } + + runtimeIDs := parseRuntimeIDs(r) + if len(runtimeIDs) == 0 { + writeError(w, http.StatusBadRequest, "runtime_ids required") + return + } + + for _, runtimeID := range runtimeIDs { + rt, ok := h.requireDaemonRuntimeAccess(w, r, runtimeID) + if !ok { + return + } + if daemonID := middleware.DaemonIDFromContext(r.Context()); daemonID != "" && rt.DaemonID.Valid && rt.DaemonID.String != daemonID { + writeError(w, http.StatusNotFound, "runtime not found") + return + } + } + + h.DaemonHub.HandleWebSocket(w, r, daemonws.ClientIdentity{ + DaemonID: middleware.DaemonIDFromContext(r.Context()), + UserID: requestUserID(r), + WorkspaceID: middleware.DaemonWorkspaceIDFromContext(r.Context()), + RuntimeIDs: runtimeIDs, + ClientVersion: r.Header.Get("X-Client-Version"), + }) +} + +func parseRuntimeIDs(r *http.Request) []string { + seen := map[string]struct{}{} + var out []string + add := func(raw string) { + for _, part := range strings.Split(raw, ",") { + id := strings.TrimSpace(part) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + for _, raw := range r.URL.Query()["runtime_id"] { + add(raw) + } + for _, raw := range r.URL.Query()["runtime_ids"] { + add(raw) + } + return out +} diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index 3b89a4c95c..039e3cce26 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -15,6 +15,7 @@ import ( "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/daemonws" "github.com/multica-ai/multica/server/internal/events" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/realtime" @@ -53,6 +54,7 @@ type Handler struct { DB dbExecutor TxStarter txStarter Hub *realtime.Hub + DaemonHub *daemonws.Hub Bus *events.Bus TaskService *service.TaskService AutopilotService *service.AutopilotService @@ -67,7 +69,7 @@ type Handler struct { cfg Config } -func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config) *Handler { +func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, analyticsClient analytics.Client, cfg Config, daemonHubs ...*daemonws.Hub) *Handler { var executor dbExecutor if candidate, ok := txStarter.(dbExecutor); ok { executor = candidate @@ -77,12 +79,18 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event analyticsClient = analytics.NoopClient{} } - taskSvc := service.NewTaskService(queries, txStarter, hub, bus) + var daemonHub *daemonws.Hub + if len(daemonHubs) > 0 { + daemonHub = daemonHubs[0] + } + + taskSvc := service.NewTaskService(queries, txStarter, hub, bus, daemonHub) return &Handler{ Queries: queries, DB: executor, TxStarter: txStarter, Hub: hub, + DaemonHub: daemonHub, Bus: bus, TaskService: taskSvc, AutopilotService: service.NewAutopilotService(queries, txStarter, bus, taskSvc), diff --git a/server/internal/metrics/daemonws.go b/server/internal/metrics/daemonws.go new file mode 100644 index 0000000000..d935ddc1ad --- /dev/null +++ b/server/internal/metrics/daemonws.go @@ -0,0 +1,70 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + + "github.com/multica-ai/multica/server/internal/daemonws" +) + +type DaemonWSCollector struct { + metrics *daemonws.Metrics + + connectsTotal *prometheus.Desc + disconnectsTotal *prometheus.Desc + activeConnections *prometheus.Desc + slowEvictionsTotal *prometheus.Desc + wakeupPublishedTotal *prometheus.Desc + wakeupPublishErrors *prometheus.Desc + wakeupReceivedTotal *prometheus.Desc + wakeupDeliveredTotal *prometheus.Desc +} + +func NewDaemonWSCollector(m *daemonws.Metrics) *DaemonWSCollector { + return &DaemonWSCollector{ + metrics: m, + + connectsTotal: newDaemonWSDesc("connects_total", "Total daemon WebSocket connections opened."), + disconnectsTotal: newDaemonWSDesc("disconnects_total", "Total daemon WebSocket connections closed."), + activeConnections: newDaemonWSDesc("active_connections", "Current daemon WebSocket connections."), + slowEvictionsTotal: newDaemonWSDesc("slow_evictions_total", "Total daemon WebSocket clients evicted for slow consumption."), + wakeupPublishedTotal: newDaemonWSDesc("wakeup_published_total", "Total daemon wakeups published to the Redis relay."), + wakeupPublishErrors: newDaemonWSDesc("wakeup_publish_errors_total", "Total daemon wakeup Redis publish errors."), + wakeupReceivedTotal: newDaemonWSDesc("wakeup_received_total", "Total daemon wakeups received from the Redis relay."), + wakeupDeliveredTotal: prometheus.NewDesc("multica_daemonws_wakeup_delivered_total", "Total daemon wakeup local delivery attempts.", []string{"result"}, nil), + } +} + +func newDaemonWSDesc(name, help string) *prometheus.Desc { + return prometheus.NewDesc("multica_daemonws_"+name, help, nil, nil) +} + +func (c *DaemonWSCollector) Describe(ch chan<- *prometheus.Desc) { + for _, desc := range []*prometheus.Desc{ + c.connectsTotal, + c.disconnectsTotal, + c.activeConnections, + c.slowEvictionsTotal, + c.wakeupPublishedTotal, + c.wakeupPublishErrors, + c.wakeupReceivedTotal, + c.wakeupDeliveredTotal, + } { + ch <- desc + } +} + +func (c *DaemonWSCollector) Collect(ch chan<- prometheus.Metric) { + if c.metrics == nil { + return + } + m := c.metrics + ch <- prometheus.MustNewConstMetric(c.connectsTotal, prometheus.CounterValue, float64(m.ConnectsTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.disconnectsTotal, prometheus.CounterValue, float64(m.DisconnectsTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.activeConnections, prometheus.GaugeValue, float64(m.ActiveConnections.Load())) + ch <- prometheus.MustNewConstMetric(c.slowEvictionsTotal, prometheus.CounterValue, float64(m.SlowEvictionsTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.wakeupPublishedTotal, prometheus.CounterValue, float64(m.WakeupPublishedTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.wakeupPublishErrors, prometheus.CounterValue, float64(m.WakeupPublishErrors.Load())) + ch <- prometheus.MustNewConstMetric(c.wakeupReceivedTotal, prometheus.CounterValue, float64(m.WakeupReceivedTotal.Load())) + ch <- prometheus.MustNewConstMetric(c.wakeupDeliveredTotal, prometheus.CounterValue, float64(m.WakeupDeliveredHit.Load()), "hit") + ch <- prometheus.MustNewConstMetric(c.wakeupDeliveredTotal, prometheus.CounterValue, float64(m.WakeupDeliveredMiss.Load()), "miss") +} diff --git a/server/internal/metrics/registry.go b/server/internal/metrics/registry.go index 4b03e83110..776dd1ff29 100644 --- a/server/internal/metrics/registry.go +++ b/server/internal/metrics/registry.go @@ -7,12 +7,14 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/multica-ai/multica/server/internal/daemonws" "github.com/multica-ai/multica/server/internal/realtime" ) type RegistryOptions struct { Pool *pgxpool.Pool Realtime *realtime.Metrics + DaemonWS *daemonws.Metrics Version string Commit string } @@ -43,6 +45,9 @@ func NewRegistry(opts RegistryOptions) *Registry { if opts.Realtime != nil { reg.MustRegister(NewRealtimeCollector(opts.Realtime)) } + if opts.DaemonWS != nil { + reg.MustRegister(NewDaemonWSCollector(opts.DaemonWS)) + } return &Registry{ Gatherer: reg, diff --git a/server/internal/realtime/broadcaster.go b/server/internal/realtime/broadcaster.go index a5aaba7ad3..a90c7ee67f 100644 --- a/server/internal/realtime/broadcaster.go +++ b/server/internal/realtime/broadcaster.go @@ -8,6 +8,9 @@ const ( ScopeUser = "user" ScopeTask = "task" ScopeChat = "chat" + // ScopeDaemonRuntime routes daemon wakeup frames through the Redis relay. + // It is consumed by the daemon WebSocket hub, not by browser clients. + ScopeDaemonRuntime = "daemon_runtime" ) // Broadcaster is the abstraction every realtime event producer should depend @@ -38,5 +41,10 @@ type Broadcaster interface { Broadcast(message []byte) } +// DaemonRuntimeDeliverer consumes daemon-runtime scoped relay frames. +type DaemonRuntimeDeliverer interface { + DeliverDaemonRuntime(scopeID string, frame []byte, eventID string) +} + // Compile-time assertion that *Hub continues to satisfy Broadcaster. var _ Broadcaster = (*Hub)(nil) diff --git a/server/internal/realtime/redis_relay.go b/server/internal/realtime/redis_relay.go index 8faf728b0c..00a8cf8faa 100644 --- a/server/internal/realtime/redis_relay.go +++ b/server/internal/realtime/redis_relay.go @@ -106,12 +106,16 @@ func redisString(v any) string { } } -func deliverEnvelope(hub *Hub, ev envelope) { +func deliverEnvelope(hub *Hub, daemonRuntime DaemonRuntimeDeliverer, ev envelope) { if ev.PayloadJSON == "" { return } frame := injectEventID([]byte(ev.PayloadJSON), ev.EventID) switch ev.Scope { + case ScopeDaemonRuntime: + if daemonRuntime != nil { + daemonRuntime.DeliverDaemonRuntime(ev.ScopeID, frame, ev.EventID) + } case "global": hub.fanoutAllDedup(frame, "", ev.EventID) case ScopeUser: @@ -134,6 +138,8 @@ type RedisRelay struct { consumers map[scopeKey]*scopeConsumer stopping bool wg sync.WaitGroup + + daemonRuntime DaemonRuntimeDeliverer } type scopeConsumer struct { @@ -167,6 +173,10 @@ func NewRedisRelayWithClients(hub *Hub, writeRDB, readRDB *redis.Client) *RedisR // NodeID returns this relay's randomly-assigned node identifier. func (r *RedisRelay) NodeID() string { return r.nodeID } +func (r *RedisRelay) SetDaemonRuntimeDeliverer(d DaemonRuntimeDeliverer) { + r.daemonRuntime = d +} + // Wait blocks until all relay-owned goroutines have exited after the Start // context is canceled. func (r *RedisRelay) Wait() { @@ -394,7 +404,7 @@ func (r *RedisRelay) deliverMessage(scopeType, scopeID string, msg redis.XMessag if ev.ScopeID == "" { ev.ScopeID = scopeID } - deliverEnvelope(r.hub, ev) + deliverEnvelope(r.hub, r.daemonRuntime, ev) } // fanoutUser is implemented in hub.go. diff --git a/server/internal/realtime/relay_lifecycle.go b/server/internal/realtime/relay_lifecycle.go index ddd742d21a..eda9bc9456 100644 --- a/server/internal/realtime/relay_lifecycle.go +++ b/server/internal/realtime/relay_lifecycle.go @@ -36,6 +36,15 @@ func (r *MirroredRelay) NodeID() string { return r.primary.NodeID() } +func (r *MirroredRelay) SetDaemonRuntimeDeliverer(d DaemonRuntimeDeliverer) { + if setter, ok := r.primary.(interface{ SetDaemonRuntimeDeliverer(DaemonRuntimeDeliverer) }); ok { + setter.SetDaemonRuntimeDeliverer(d) + } + if setter, ok := r.mirror.(interface{ SetDaemonRuntimeDeliverer(DaemonRuntimeDeliverer) }); ok { + setter.SetDaemonRuntimeDeliverer(d) + } +} + func (r *MirroredRelay) Start(ctx context.Context) { r.primary.Start(ctx) r.mirror.Start(ctx) @@ -74,6 +83,9 @@ func (r *MirroredRelay) Broadcast(message []byte) { func (r *MirroredRelay) PublishWithID(scopeType, scopeID, exclude string, frame []byte, id string) error { primaryErr := r.primary.PublishWithID(scopeType, scopeID, exclude, frame, id) + if scopeType == ScopeDaemonRuntime { + return primaryErr + } mirrorErr := r.mirror.PublishWithID(scopeType, scopeID, exclude, frame, id) if primaryErr != nil { diff --git a/server/internal/realtime/relay_lifecycle_test.go b/server/internal/realtime/relay_lifecycle_test.go index 8c035a828d..8e38fa008b 100644 --- a/server/internal/realtime/relay_lifecycle_test.go +++ b/server/internal/realtime/relay_lifecycle_test.go @@ -50,6 +50,23 @@ func TestMirroredRelayRecordsDivergenceWhenOneBackendFails(t *testing.T) { } } +func TestMirroredRelayDoesNotMirrorDaemonRuntimeEvents(t *testing.T) { + primary := &recordingManagedRelay{nodeID: "primary"} + mirror := &recordingManagedRelay{nodeID: "mirror"} + relay := NewMirroredRelay(primary, mirror) + + if err := relay.PublishWithID(ScopeDaemonRuntime, "task-1", "", []byte(`{"type":"daemon:task_available"}`), "event-1"); err != nil { + t.Fatalf("PublishWithID: %v", err) + } + + if len(primary.calls) != 1 { + t.Fatalf("expected primary publish call, got %d", len(primary.calls)) + } + if len(mirror.calls) != 0 { + t.Fatalf("expected daemon runtime event not to hit mirror, got %d calls", len(mirror.calls)) + } +} + type relayPublishCall struct { scopeType string scopeID string diff --git a/server/internal/realtime/sharded_stream_relay.go b/server/internal/realtime/sharded_stream_relay.go index 41d1c4e28f..04c3da722c 100644 --- a/server/internal/realtime/sharded_stream_relay.go +++ b/server/internal/realtime/sharded_stream_relay.go @@ -76,6 +76,8 @@ type ShardedStreamRelay struct { mu sync.Mutex stopping bool wg sync.WaitGroup + + daemonRuntime DaemonRuntimeDeliverer } func NewShardedStreamRelay(hub *Hub, writeRDB, readRDB *redis.Client, config ShardedStreamRelayConfig) *ShardedStreamRelay { @@ -93,6 +95,10 @@ func NewShardedStreamRelay(hub *Hub, writeRDB, readRDB *redis.Client, config Sha func (r *ShardedStreamRelay) NodeID() string { return r.nodeID } +func (r *ShardedStreamRelay) SetDaemonRuntimeDeliverer(d DaemonRuntimeDeliverer) { + r.daemonRuntime = d +} + func (r *ShardedStreamRelay) Start(ctx context.Context) { M.NodeID.Store(r.nodeID) if err := r.writeRDB.Ping(ctx).Err(); err != nil { @@ -233,7 +239,7 @@ func (r *ShardedStreamRelay) deliverMessage(msg redis.XMessage) { if !ok || ev.Scope == "" || ev.ScopeID == "" { return } - deliverEnvelope(r.hub, ev) + deliverEnvelope(r.hub, r.daemonRuntime, ev) } func (r *ShardedStreamRelay) heartbeatLoop(ctx context.Context) { diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 52d1ed7b11..09cfb3efd2 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -26,10 +26,19 @@ type TaskService struct { TxStarter TxStarter Hub *realtime.Hub Bus *events.Bus + Wakeup TaskWakeupNotifier } -func NewTaskService(q *db.Queries, tx TxStarter, hub *realtime.Hub, bus *events.Bus) *TaskService { - return &TaskService{Queries: q, TxStarter: tx, Hub: hub, Bus: bus} +type TaskWakeupNotifier interface { + NotifyTaskAvailable(runtimeID, taskID string) +} + +func NewTaskService(q *db.Queries, tx TxStarter, hub *realtime.Hub, bus *events.Bus, wakeups ...TaskWakeupNotifier) *TaskService { + var wakeup TaskWakeupNotifier + if len(wakeups) > 0 { + wakeup = wakeups[0] + } + return &TaskService{Queries: q, TxStarter: tx, Hub: hub, Bus: bus, Wakeup: wakeup} } // EnqueueTaskForIssue creates a queued task for an agent-assigned issue. @@ -73,6 +82,7 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue, t } slog.Info("task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(issue.AssigneeID)) + s.notifyTaskAvailable(task) return task, nil } @@ -107,6 +117,7 @@ func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue, } slog.Info("mention task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID)) + s.notifyTaskAvailable(task) return task, nil } @@ -137,6 +148,7 @@ func (s *TaskService) EnqueueChatTask(ctx context.Context, chatSession db.ChatSe } slog.Info("chat task enqueued", "task_id", util.UUIDToString(task.ID), "chat_session_id", util.UUIDToString(chatSession.ID), "agent_id", util.UUIDToString(chatSession.AgentID)) + s.notifyTaskAvailable(task) return task, nil } @@ -645,6 +657,7 @@ func (s *TaskService) MaybeRetryFailedTask(ctx context.Context, parent db.AgentT "attempt", child.Attempt, "max_attempts", child.MaxAttempts, ) + s.notifyTaskAvailable(child) s.broadcastTaskEvent(ctx, protocol.EventTaskDispatch, child) return &child, nil } @@ -902,6 +915,13 @@ func priorityToInt(p string) int32 { } } +func (s *TaskService) notifyTaskAvailable(task db.AgentTaskQueue) { + if s.Wakeup == nil || !task.RuntimeID.Valid { + return + } + s.Wakeup.NotifyTaskAvailable(util.UUIDToString(task.RuntimeID), util.UUIDToString(task.ID)) +} + func (s *TaskService) broadcastTaskDispatch(ctx context.Context, task db.AgentTaskQueue) { var payload map[string]any if task.Context != nil { diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index b5ba64e834..1ca5d9ee7e 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -11,10 +11,10 @@ const ( EventCommentCreated = "comment:created" EventCommentUpdated = "comment:updated" EventCommentDeleted = "comment:deleted" - EventReactionAdded = "reaction:added" - EventReactionRemoved = "reaction:removed" - EventIssueReactionAdded = "issue_reaction:added" - EventIssueReactionRemoved = "issue_reaction:removed" + EventReactionAdded = "reaction:added" + EventReactionRemoved = "reaction:removed" + EventIssueReactionAdded = "issue_reaction:added" + EventIssueReactionRemoved = "issue_reaction:removed" // Agent events EventAgentStatus = "agent:status" @@ -93,6 +93,7 @@ const ( EventAutopilotRunDone = "autopilot:run_done" // Daemon events - EventDaemonHeartbeat = "daemon:heartbeat" - EventDaemonRegister = "daemon:register" + EventDaemonHeartbeat = "daemon:heartbeat" + EventDaemonRegister = "daemon:register" + EventDaemonTaskAvailable = "daemon:task_available" ) diff --git a/server/pkg/protocol/messages.go b/server/pkg/protocol/messages.go index 3e11e8eb80..d98d01456b 100644 --- a/server/pkg/protocol/messages.go +++ b/server/pkg/protocol/messages.go @@ -16,6 +16,13 @@ type TaskDispatchPayload struct { Description string `json:"description"` } +// TaskAvailablePayload is sent from server to daemon as a wakeup hint. The +// daemon still claims work through the existing HTTP claim endpoint. +type TaskAvailablePayload struct { + RuntimeID string `json:"runtime_id"` + TaskID string `json:"task_id,omitempty"` +} + // TaskProgressPayload is sent from daemon to server during task execution. type TaskProgressPayload struct { TaskID string `json:"task_id"` @@ -37,10 +44,10 @@ type TaskMessagePayload struct { IssueID string `json:"issue_id,omitempty"` Seq int `json:"seq"` Type string `json:"type"` // "text", "tool_use", "tool_result", "error" - Tool string `json:"tool,omitempty"` // tool name for tool_use/tool_result - Content string `json:"content,omitempty"` // text content - Input map[string]any `json:"input,omitempty"` // tool input (tool_use only) - Output string `json:"output,omitempty"` // tool output (tool_result only) + Tool string `json:"tool,omitempty"` // tool name for tool_use/tool_result + Content string `json:"content,omitempty"` // text content + Input map[string]any `json:"input,omitempty"` // tool input (tool_use only) + Output string `json:"output,omitempty"` // tool output (tool_result only) } // DaemonRegisterPayload is sent from daemon to server on connection. From 246fcd4ce44900238329ee384fc5ff88b093db62 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:13:56 +0800 Subject: [PATCH 27/38] feat(issues): server-side filters incl. label, fixing pagination drops (#1776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(issues): server-side label + filter querying for issue list Extends GET /api/issues with label_ids, priorities, creator_ids, project_ids, include_no_assignee, and include_no_project params, and moves the existing single-value filters onto array-form. Each filter becomes part of the SQL WHERE clause so paginated buckets reflect the user's selection — fixes the bug where client-side filtering hid matches sitting past the first page (#1491). CLI gains a repeatable --label flag; legacy --priority/--assignee/ --project keep working via the single-value compatibility paths. * feat(issues): drive workspace + my-issues filters from the server issueListOptions and myIssueListOptions now key the React Query cache on a normalized filter object, so each filter combination has its own cache entry and a filter change re-fetches with the wire-shape filter applied server-side. Drops the client-side filterIssues step on the issues page, my-issues page, and project detail — that step silently hid matches that lived past the first paginated page (#1491). Adds a Label submenu to the workspace issues filter dropdown, plus labelFilters in the view store. Mutations and ws-updaters fan their optimistic patches across every filter-keyed list cache via qc.setQueriesData on issueKeys.listPrefix(wsId), and the editor's mention-suggestion reads from any matching list cache for instant first paint regardless of which filter is active. * fix(issues): route Members/Agents scope through server-side filter The Members/Agents scope tabs on the workspace issues page were still narrowing client-side via `assignee_type === 'member'`. That hits the exact pagination-blind bug this PR is meant to fix: if the first 50 issues per status don't include the right assignee type, the tab shows "No issues" while later pages have matches. Adds an `assignee_types text[]` filter to ListIssues / ListOpenIssues / CountIssues, threads it through the API client, normalizer and view filter, and maps the scope tab to it. Each scope now keys its own list cache and refetches with the correct first page. Also disables the My Issues "My Agents" query when the user owns no agents — `assignee_ids: []` was getting dropped by both the API client and the query-key normalizer, so the request went out unfiltered and surfaced unrelated issues under "My Agents". --- packages/core/api/client.ts | 11 +- packages/core/issues/mutations.ts | 135 ++++++++---- packages/core/issues/queries.ts | 59 ++++-- packages/core/issues/stores/view-store.ts | 11 + packages/core/issues/ws-updaters.ts | 51 ++++- packages/core/types/api.ts | 15 +- .../extensions/mention-suggestion.test.tsx | 36 +++- .../editor/extensions/mention-suggestion.tsx | 18 +- .../views/issues/components/board-view.tsx | 43 ++-- .../views/issues/components/issues-header.tsx | 104 ++++++++- .../issues/components/issues-page.test.tsx | 2 + .../views/issues/components/issues-page.tsx | 52 +++-- .../views/issues/components/list-view.tsx | 27 ++- packages/views/issues/utils/filter.test.ts | 199 ++++++------------ packages/views/issues/utils/filter.ts | 101 ++++----- .../my-issues/components/my-issues-page.tsx | 49 ++--- .../projects/components/project-detail.tsx | 46 ++-- server/cmd/multica/cmd_issue.go | 4 + server/internal/handler/handler.go | 28 +++ server/internal/handler/issue.go | 141 ++++++++----- server/pkg/db/generated/issue.sql.go | 155 +++++++++----- server/pkg/db/queries/issue.sql | 69 ++++-- 22 files changed, 885 insertions(+), 471 deletions(-) diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 8822d4f917..8ba3b241f3 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -363,11 +363,14 @@ export class ApiClient { if (params?.offset) search.set("offset", String(params.offset)); if (params?.workspace_id) search.set("workspace_id", params.workspace_id); if (params?.status) search.set("status", params.status); - if (params?.priority) search.set("priority", params.priority); - if (params?.assignee_id) search.set("assignee_id", params.assignee_id); + if (params?.priorities?.length) search.set("priorities", params.priorities.join(",")); + if (params?.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); - if (params?.creator_id) search.set("creator_id", params.creator_id); - if (params?.project_id) search.set("project_id", params.project_id); + if (params?.include_no_assignee) search.set("include_no_assignee", "true"); + if (params?.creator_ids?.length) search.set("creator_ids", params.creator_ids.join(",")); + if (params?.project_ids?.length) search.set("project_ids", params.project_ids.join(",")); + if (params?.include_no_project) search.set("include_no_project", "true"); + if (params?.label_ids?.length) search.set("label_ids", params.label_ids.join(",")); if (params?.open_only) search.set("open_only", "true"); return this.fetch(`/api/issues?${search}`); } diff --git a/packages/core/issues/mutations.ts b/packages/core/issues/mutations.ts index f9bddbd3ac..0e7544071f 100644 --- a/packages/core/issues/mutations.ts +++ b/packages/core/issues/mutations.ts @@ -1,9 +1,10 @@ import { useState, useCallback } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { api } from "../api"; import { issueKeys, ISSUE_PAGE_SIZE, + type IssueListFilter, type MyIssuesFilter, } from "./queries"; import { @@ -24,6 +25,60 @@ import type { } from "../types"; import type { TimelineEntry, IssueSubscriber, Reaction } from "../types"; +// --------------------------------------------------------------------------- +// Cache helpers — apply an updater to every filter-keyed list cache. +// --------------------------------------------------------------------------- + +/** + * Calls `updater` against every workspace issue-list cache (all filter + * combinations under `["issues", wsId, "list", *]`). Filter-keyed caches mean + * the same workspace can have many list entries; mutations need to update or + * snapshot all of them so the UI stays consistent regardless of which filter + * the user has active. + */ +function updateAllListCaches( + qc: QueryClient, + wsId: string, + updater: (old: ListIssuesCache | undefined) => ListIssuesCache | undefined, +) { + return qc.setQueriesData( + { queryKey: issueKeys.listPrefix(wsId) }, + updater, + ); +} + +/** Snapshot every workspace list cache so mutations can roll back on error. */ +function snapshotAllListCaches(qc: QueryClient, wsId: string) { + return qc + .getQueriesData({ queryKey: issueKeys.listPrefix(wsId) }) + .map(([key, data]) => ({ key, data })); +} + +function restoreListCacheSnapshots( + qc: QueryClient, + snapshots: { key: readonly unknown[]; data: ListIssuesCache | undefined }[], +) { + for (const { key, data } of snapshots) { + if (data !== undefined) qc.setQueryData(key, data); + } +} + +function findIssueAcrossListCaches( + qc: QueryClient, + wsId: string, + issueId: string, +) { + const entries = qc.getQueriesData({ + queryKey: issueKeys.listPrefix(wsId), + }); + for (const [, cache] of entries) { + if (!cache) continue; + const loc = findIssueLocation(cache, issueId); + if (loc) return loc.issue; + } + return undefined; +} + // --------------------------------------------------------------------------- // Shared mutation variable types — used by both mutation hooks and // useMutationState consumers to keep the type assertion in sync. @@ -51,15 +106,17 @@ export type ToggleIssueReactionVars = { */ export function useLoadMoreByStatus( status: IssueStatus, - myIssues?: { scope: string; filter: MyIssuesFilter }, + options: { filter?: IssueListFilter; myIssues?: { scope: string; filter: MyIssuesFilter } } = {}, ) { + const { filter, myIssues } = options; const qc = useQueryClient(); const wsId = useWorkspaceId(); const [isLoading, setIsLoading] = useState(false); const queryKey = myIssues ? issueKeys.myList(wsId, myIssues.scope, myIssues.filter) - : issueKeys.list(wsId); + : issueKeys.list(wsId, filter); + const requestFilter = myIssues?.filter ?? filter ?? {}; const cache = qc.getQueryData(queryKey); const bucket = cache?.byStatus[status]; const loaded = bucket?.issues.length ?? 0; @@ -74,7 +131,7 @@ export function useLoadMoreByStatus( status, limit: ISSUE_PAGE_SIZE, offset: loaded, - ...myIssues?.filter, + ...requestFilter, }); qc.setQueryData(queryKey, (old) => { if (!old) return old; @@ -89,7 +146,7 @@ export function useLoadMoreByStatus( } finally { setIsLoading(false); } - }, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]); + }, [qc, queryKey, status, loaded, hasMore, isLoading, requestFilter]); return { loadMore, hasMore, isLoading, total }; } @@ -104,7 +161,11 @@ export function useCreateIssue() { return useMutation({ mutationFn: (data: CreateIssueRequest) => api.createIssue(data), onSuccess: (newIssue) => { - qc.setQueryData(issueKeys.list(wsId), (old) => + // Insert the new issue into every active list cache. Filter-keyed + // caches are invalidated on settled below, so a momentary inclusion + // in a cache that "shouldn't" contain the issue is corrected within + // the same tick. + updateAllListCaches(qc, wsId, (old) => old ? addIssueToBuckets(old, newIssue) : old, ); // Surface the just-created issue in cmd+k's Recent list without @@ -117,7 +178,7 @@ export function useCreateIssue() { } }, onSettled: () => { - qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); }, }); } @@ -133,8 +194,8 @@ export function useUpdateIssue() { // cache update happens in the same tick as mutate(). Awaiting would // yield to the event loop, letting @dnd-kit reset its visual state // before the optimistic update lands. - qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); - const prevList = qc.getQueryData(issueKeys.list(wsId)); + qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); + const listSnapshots = snapshotAllListCaches(qc, wsId); const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); // Resolve parent_issue_id from the freshest source so we can keep the @@ -142,13 +203,13 @@ export function useUpdateIssue() { // sub-issues list). const parentId = prevDetail?.parent_issue_id ?? - (prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ?? + findIssueAcrossListCaches(qc, wsId, id)?.parent_issue_id ?? null; const prevChildren = parentId ? qc.getQueryData(issueKeys.children(wsId, parentId)) : undefined; - qc.setQueryData(issueKeys.list(wsId), (old) => + updateAllListCaches(qc, wsId, (old) => old ? patchIssueInBuckets(old, id, data) : old, ); qc.setQueryData(issueKeys.detail(wsId, id), (old) => @@ -161,10 +222,10 @@ export function useUpdateIssue() { old?.map((c) => (c.id === id ? { ...c, ...data } : c)), ); } - return { prevList, prevDetail, prevChildren, parentId, id }; + return { listSnapshots, prevDetail, prevChildren, parentId, id }; }, onError: (_err, _vars, ctx) => { - if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); if (ctx?.prevDetail) qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail); if (ctx?.parentId && ctx.prevChildren !== undefined) { @@ -176,7 +237,7 @@ export function useUpdateIssue() { }, onSettled: (_data, _err, vars, ctx) => { qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) }); - qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); // Invalidate old parent's children cache if (ctx?.parentId) { qc.invalidateQueries({ @@ -202,20 +263,20 @@ export function useDeleteIssue() { return useMutation({ mutationFn: (id: string) => api.deleteIssue(id), onMutate: async (id) => { - await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); - const prevList = qc.getQueryData(issueKeys.list(wsId)); - const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined; - qc.setQueryData(issueKeys.list(wsId), (old) => + await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); + const listSnapshots = snapshotAllListCaches(qc, wsId); + const deleted = findIssueAcrossListCaches(qc, wsId, id); + updateAllListCaches(qc, wsId, (old) => old ? removeIssueFromBuckets(old, id) : old, ); qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) }); - return { prevList, parentIssueId: deleted?.parent_issue_id }; + return { listSnapshots, parentIssueId: deleted?.parent_issue_id }; }, onError: (_err, _id, ctx) => { - if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); }, onSettled: (_data, _err, _id, ctx) => { - qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); if (ctx?.parentIssueId) { qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) }); qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) }); @@ -236,21 +297,21 @@ export function useBatchUpdateIssues() { updates: UpdateIssueRequest; }) => api.batchUpdateIssues(ids, updates), onMutate: async ({ ids, updates }) => { - await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); - const prevList = qc.getQueryData(issueKeys.list(wsId)); - qc.setQueryData(issueKeys.list(wsId), (old) => { + await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); + const listSnapshots = snapshotAllListCaches(qc, wsId); + updateAllListCaches(qc, wsId, (old) => { if (!old) return old; let next = old; for (const id of ids) next = patchIssueInBuckets(next, id, updates); return next; }); - return { prevList }; + return { listSnapshots }; }, onError: (_err, _vars, ctx) => { - if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); }, onSettled: () => { - qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); }, }); } @@ -261,28 +322,26 @@ export function useBatchDeleteIssues() { return useMutation({ mutationFn: (ids: string[]) => api.batchDeleteIssues(ids), onMutate: async (ids) => { - await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); - const prevList = qc.getQueryData(issueKeys.list(wsId)); + await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); + const listSnapshots = snapshotAllListCaches(qc, wsId); const parentIssueIds = new Set(); - if (prevList) { - for (const id of ids) { - const loc = findIssueLocation(prevList, id); - if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id); - } + for (const id of ids) { + const found = findIssueAcrossListCaches(qc, wsId, id); + if (found?.parent_issue_id) parentIssueIds.add(found.parent_issue_id); } - qc.setQueryData(issueKeys.list(wsId), (old) => { + updateAllListCaches(qc, wsId, (old) => { if (!old) return old; let next = old; for (const id of ids) next = removeIssueFromBuckets(next, id); return next; }); - return { prevList, parentIssueIds }; + return { listSnapshots, parentIssueIds }; }, onError: (_err, _ids, ctx) => { - if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); + if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); }, onSettled: (_data, _err, _ids, ctx) => { - qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) { for (const parentId of ctx.parentIssueIds) { qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) }); diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index dd1a35679b..f84e331fd7 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -5,12 +5,14 @@ import { BOARD_STATUSES } from "./config"; export const issueKeys = { all: (wsId: string) => ["issues", wsId] as const, - list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const, + /** Filter-keyed list. Empty filter is the canonical workspace-wide list. */ + list: (wsId: string, filter: IssueListFilter = EMPTY_FILTER) => + [...issueKeys.all(wsId), "list", normalizeFilter(filter)] as const, /** All "my issues" queries — use for bulk invalidation. */ myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const, /** Per-scope "my issues" list with filter identity baked into the key. */ - myList: (wsId: string, scope: string, filter: MyIssuesFilter) => - [...issueKeys.myAll(wsId), scope, filter] as const, + myList: (wsId: string, scope: string, filter: IssueListFilter) => + [...issueKeys.myAll(wsId), scope, normalizeFilter(filter)] as const, detail: (wsId: string, id: string) => [...issueKeys.all(wsId), "detail", id] as const, children: (wsId: string, id: string) => @@ -22,13 +24,44 @@ export const issueKeys = { subscribers: (issueId: string) => ["issues", "subscribers", issueId] as const, usage: (issueId: string) => ["issues", "usage", issueId] as const, + /** Prefix used by mutations to broadcast cache updates across all filters. */ + listPrefix: (wsId: string) => [...issueKeys.all(wsId), "list"] as const, }; -export type MyIssuesFilter = Pick< +/** + * Server-side filter passed to `GET /api/issues`. Status is excluded because + * the cache buckets per-status — each bucket is fetched with its own status + * query param (see {@link fetchFirstPages}). + */ +export type IssueListFilter = Omit< ListIssuesParams, - "assignee_id" | "assignee_ids" | "creator_id" | "project_id" + "limit" | "offset" | "workspace_id" | "status" | "open_only" >; +/** Backwards-compat alias — old name was specific to the My Issues page. */ +export type MyIssuesFilter = IssueListFilter; + +const EMPTY_FILTER: IssueListFilter = {}; + +/** + * Normalizes a filter object so semantically-identical filters produce + * identical query keys. Sorts arrays (so `[a, b]` and `[b, a]` cache to the + * same key) and drops empty/falsy entries (so `{}` and `{ priorities: [] }` + * are equivalent). + */ +function normalizeFilter(filter: IssueListFilter): IssueListFilter { + const out: IssueListFilter = {}; + if (filter.priorities?.length) out.priorities = [...filter.priorities].sort(); + if (filter.assignee_types?.length) out.assignee_types = [...filter.assignee_types].sort(); + if (filter.assignee_ids?.length) out.assignee_ids = [...filter.assignee_ids].sort(); + if (filter.include_no_assignee) out.include_no_assignee = true; + if (filter.creator_ids?.length) out.creator_ids = [...filter.creator_ids].sort(); + if (filter.project_ids?.length) out.project_ids = [...filter.project_ids].sort(); + if (filter.include_no_project) out.include_no_project = true; + if (filter.label_ids?.length) out.label_ids = [...filter.label_ids].sort(); + return out; +} + /** Page size per status column. */ export const ISSUE_PAGE_SIZE = 50; @@ -45,7 +78,7 @@ export function flattenIssueBuckets(data: ListIssuesCache) { return out; } -async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise { +async function fetchFirstPages(filter: IssueListFilter = {}): Promise { const responses = await Promise.all( PAGINATED_STATUSES.map((status) => api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }), @@ -65,13 +98,15 @@ async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise(...)` and preserve the byStatus shape. * - * Fetches the first page of each paginated status in parallel. Use - * {@link useLoadMoreByStatus} to paginate a specific status into the cache. + * Fetches the first page of each paginated status in parallel. Filter goes + * into both the cache key and the request, so filter changes trigger a + * fresh server-side fetch and don't share cache with other filters. Use + * {@link useLoadMoreByStatus} to paginate a specific status. */ -export function issueListOptions(wsId: string) { +export function issueListOptions(wsId: string, filter: IssueListFilter = EMPTY_FILTER) { return queryOptions({ - queryKey: issueKeys.list(wsId), - queryFn: () => fetchFirstPages(), + queryKey: issueKeys.list(wsId, filter), + queryFn: () => fetchFirstPages(filter), select: flattenIssueBuckets, }); } @@ -83,7 +118,7 @@ export function issueListOptions(wsId: string) { export function myIssueListOptions( wsId: string, scope: string, - filter: MyIssuesFilter, + filter: IssueListFilter, ) { return queryOptions({ queryKey: issueKeys.myList(wsId, scope, filter), diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index c704ba62b4..e61b8f15e3 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -55,6 +55,7 @@ export interface IssueViewState { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; + labelFilters: string[]; sortBy: SortField; sortDirection: SortDirection; cardProperties: CardProperties; @@ -67,6 +68,7 @@ export interface IssueViewState { toggleCreatorFilter: (value: ActorFilterValue) => void; toggleProjectFilter: (projectId: string) => void; toggleNoProject: () => void; + toggleLabelFilter: (labelId: string) => void; hideStatus: (status: IssueStatus) => void; showStatus: (status: IssueStatus) => void; clearFilters: () => void; @@ -85,6 +87,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, + labelFilters: [], sortBy: "position", sortDirection: "asc", cardProperties: { @@ -147,6 +150,12 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue })), toggleNoProject: () => set((state) => ({ includeNoProject: !state.includeNoProject })), + toggleLabelFilter: (labelId) => + set((state) => ({ + labelFilters: state.labelFilters.includes(labelId) + ? state.labelFilters.filter((id) => id !== labelId) + : [...state.labelFilters, labelId], + })), hideStatus: (status) => set((state) => { // If no filter active, activate filter with all EXCEPT this one @@ -172,6 +181,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, + labelFilters: [], }), setSortBy: (field) => set({ sortBy: field }), setSortDirection: (dir) => set({ sortDirection: dir }), @@ -202,6 +212,7 @@ export const viewStorePersistOptions = (name: string) => ({ creatorFilters: state.creatorFilters, projectFilters: state.projectFilters, includeNoProject: state.includeNoProject, + labelFilters: state.labelFilters, sortBy: state.sortBy, sortDirection: state.sortDirection, cardProperties: state.cardProperties, diff --git a/packages/core/issues/ws-updaters.ts b/packages/core/issues/ws-updaters.ts index 299019fb11..1e137135c6 100644 --- a/packages/core/issues/ws-updaters.ts +++ b/packages/core/issues/ws-updaters.ts @@ -9,14 +9,45 @@ import { import type { Issue, Label } from "../types"; import type { ListIssuesCache } from "../types"; +/** Apply an updater to every workspace list cache (all filter combinations). */ +function updateAllListCaches( + qc: QueryClient, + wsId: string, + updater: (old: ListIssuesCache | undefined) => ListIssuesCache | undefined, +) { + qc.setQueriesData( + { queryKey: issueKeys.listPrefix(wsId) }, + updater, + ); +} + +function findIssueAcrossListCaches( + qc: QueryClient, + wsId: string, + issueId: string, +) { + const entries = qc.getQueriesData({ + queryKey: issueKeys.listPrefix(wsId), + }); + for (const [, cache] of entries) { + if (!cache) continue; + const loc = findIssueLocation(cache, issueId); + if (loc) return loc.issue; + } + return undefined; +} + export function onIssueCreated( qc: QueryClient, wsId: string, issue: Issue, ) { - qc.setQueryData(issueKeys.list(wsId), (old) => + // Insert into every active list cache. Filter mismatches are corrected + // by the trailing `invalidateQueries` on the listPrefix. + updateAllListCaches(qc, wsId, (old) => old ? addIssueToBuckets(old, issue) : old, ); + qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); if (issue.parent_issue_id) { qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) }); @@ -32,18 +63,17 @@ export function onIssueUpdated( // Look up the OLD parent before mutating list state, so we can keep // the parent's children cache in sync (powers the sub-issues list // shown on the parent issue page). - const listData = qc.getQueryData(issueKeys.list(wsId)); const detailData = qc.getQueryData(issueKeys.detail(wsId, issue.id)); const oldParentId = detailData?.parent_issue_id ?? - (listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ?? + findIssueAcrossListCaches(qc, wsId, issue.id)?.parent_issue_id ?? null; // The NEW parent comes from the WS payload when parent_issue_id changed const newParentId = issue.parent_issue_id ?? null; const parentChanged = issue.parent_issue_id !== undefined && newParentId !== oldParentId; - qc.setQueryData(issueKeys.list(wsId), (old) => + updateAllListCaches(qc, wsId, (old) => old ? patchIssueInBuckets(old, issue.id, issue) : old, ); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); @@ -76,6 +106,9 @@ export function onIssueUpdated( * Patch an issue's `labels` field in-place across the list cache, my-issues * caches, and the detail cache. Triggered by the `issue_labels:changed` WS * event after attach/detach so list/board chips update without a refetch. + * + * Also invalidates the listPrefix because a label change can move issues in + * or out of an active label filter. */ export function onIssueLabelsChanged( qc: QueryClient, @@ -83,12 +116,13 @@ export function onIssueLabelsChanged( issueId: string, labels: Label[], ) { - qc.setQueryData(issueKeys.list(wsId), (old) => + updateAllListCaches(qc, wsId, (old) => old ? patchIssueInBuckets(old, issueId, { labels }) : old, ); qc.setQueryData(issueKeys.detail(wsId, issueId), (old) => old ? { ...old, labels } : old, ); + qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); } @@ -97,11 +131,10 @@ export function onIssueDeleted( wsId: string, issueId: string, ) { - // Look up the issue before removing it to check for parent_issue_id - const listData = qc.getQueryData(issueKeys.list(wsId)); - const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined; + // Look up the issue before removing it to check for parent_issue_id. + const deleted = findIssueAcrossListCaches(qc, wsId, issueId); - qc.setQueryData(issueKeys.list(wsId), (old) => + updateAllListCaches(qc, wsId, (old) => old ? removeIssueFromBuckets(old, issueId) : old, ); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index aa6e0bca9a..80aa6e923c 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -34,11 +34,18 @@ export interface ListIssuesParams { offset?: number; workspace_id?: string; status?: IssueStatus; - priority?: IssuePriority; - assignee_id?: string; + priorities?: IssuePriority[]; + /** Match issues whose assignee is one of these polymorphic types ("member"/"agent"). */ + assignee_types?: IssueAssigneeType[]; assignee_ids?: string[]; - creator_id?: string; - project_id?: string; + /** When true, also include issues with no assignee (OR'd with assignee_ids). */ + include_no_assignee?: boolean; + creator_ids?: string[]; + project_ids?: string[]; + /** When true, also include issues with no project (OR'd with project_ids). */ + include_no_project?: boolean; + /** Match issues that have at least one of these labels. */ + label_ids?: string[]; open_only?: boolean; } diff --git a/packages/views/editor/extensions/mention-suggestion.test.tsx b/packages/views/editor/extensions/mention-suggestion.test.tsx index ef60322fe0..0a39f6415b 100644 --- a/packages/views/editor/extensions/mention-suggestion.test.tsx +++ b/packages/views/editor/extensions/mention-suggestion.test.tsx @@ -26,20 +26,40 @@ function fakeQc(data: { agents?: Array<{ id: string; name: string; archived_at: string | null }>; issues?: Array<{ id: string; identifier: string; title: string; status: string }>; }): QueryClient { - const map = new Map(); - map.set(JSON.stringify(workspaceKeys.members("ws-1")), data.members ?? []); - map.set(JSON.stringify(workspaceKeys.agents("ws-1")), data.agents ?? []); + const map = new Map(); + map.set(JSON.stringify(workspaceKeys.members("ws-1")), { + key: workspaceKeys.members("ws-1"), + data: data.members ?? [], + }); + map.set(JSON.stringify(workspaceKeys.agents("ws-1")), { + key: workspaceKeys.agents("ws-1"), + data: data.agents ?? [], + }); const byStatus: ListIssuesCache["byStatus"] = {}; for (const status of PAGINATED_STATUSES) { const bucket = (data.issues ?? []).filter((i) => i.status === status); byStatus[status as IssueStatus] = { issues: bucket as never, total: bucket.length }; } - map.set( - JSON.stringify(issueKeys.list("ws-1")), - { byStatus } satisfies ListIssuesCache, - ); + const listKey = issueKeys.list("ws-1"); + map.set(JSON.stringify(listKey), { + key: listKey, + data: { byStatus } satisfies ListIssuesCache, + }); return { - getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)), + getQueryData: (key: readonly unknown[]) => + map.get(JSON.stringify(key))?.data, + // Prefix-match implementation: any cached key whose prefix equals the + // filter's queryKey is returned. Mirrors TanStack's behavior closely + // enough for our cache-read paths. + getQueriesData: ({ queryKey }: { queryKey: readonly unknown[] }) => { + const prefix = JSON.stringify(queryKey); + const trimmed = prefix.slice(0, -1); // strip trailing ']' + const out: Array = []; + for (const [k, entry] of map.entries()) { + if (k.startsWith(trimmed)) out.push([entry.key, entry.data] as const); + } + return out; + }, } as unknown as QueryClient; } diff --git a/packages/views/editor/extensions/mention-suggestion.tsx b/packages/views/editor/extensions/mention-suggestion.tsx index 000cad1f4d..659e8ca794 100644 --- a/packages/views/editor/extensions/mention-suggestion.tsx +++ b/packages/views/editor/extensions/mention-suggestion.tsx @@ -254,8 +254,22 @@ export function createMentionSuggestion(qc: QueryClient): Omit< const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? []; const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? []; - const cachedResponse = qc.getQueryData(issueKeys.list(wsId)); - const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : []; + // List caches are filter-keyed, so a single workspace can have multiple + // entries. Pull from whichever caches exist and dedupe — the goal is just + // an instant first paint; the server search below fills in misses. + const cachedListEntries = qc.getQueriesData({ + queryKey: issueKeys.listPrefix(wsId), + }); + const seen = new Set(); + const cachedIssues: Issue[] = []; + for (const [, cache] of cachedListEntries) { + if (!cache) continue; + for (const issue of flattenIssueBuckets(cache)) { + if (seen.has(issue.id)) continue; + seen.add(issue.id); + cachedIssues.push(issue); + } + } const q = query.toLowerCase(); diff --git a/packages/views/issues/components/board-view.tsx b/packages/views/issues/components/board-view.tsx index 4cf96b1e67..4093e93717 100644 --- a/packages/views/issues/components/board-view.tsx +++ b/packages/views/issues/components/board-view.tsx @@ -19,7 +19,17 @@ import { Eye, MoreHorizontal } from "lucide-react"; import type { Issue, IssueStatus } from "@multica/core/types"; import { Button } from "@multica/ui/components/ui/button"; import { useLoadMoreByStatus } from "@multica/core/issues/mutations"; -import type { MyIssuesFilter } from "@multica/core/issues/queries"; +import type { IssueListFilter, MyIssuesFilter } from "@multica/core/issues/queries"; + +/** + * Threaded between BoardView and its inner components — selects which list + * cache `useLoadMoreByStatus` paginates. The workspace list path keys on + * `filter`; the My Issues path keys on `(scope, filter)` because each scope + * has its own cache entry. + */ +type LoadMoreOptions = + | { filter?: IssueListFilter; myIssues?: never } + | { filter?: never; myIssues: { scope: string; filter: MyIssuesFilter } }; import { DropdownMenu, DropdownMenuTrigger, @@ -103,6 +113,7 @@ export function BoardView({ hiddenStatuses, onMoveIssue, childProgressMap = EMPTY_PROGRESS_MAP, + listFilter, myIssuesScope, myIssuesFilter, }: { @@ -115,15 +126,17 @@ export function BoardView({ newPosition?: number ) => void; childProgressMap?: Map; + /** Filter that keys the workspace list cache. Threaded into useLoadMoreByStatus so pagination targets the same filtered bucket the page is rendering. */ + listFilter?: IssueListFilter; /** When set, per-status load-more targets the scoped cache instead of the workspace one. */ myIssuesScope?: string; myIssuesFilter?: MyIssuesFilter; }) { const sortBy = useViewStore((s) => s.sortBy); const sortDirection = useViewStore((s) => s.sortDirection); - const myIssuesOpts = myIssuesScope - ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } - : undefined; + const loadMoreOptions = myIssuesScope + ? { myIssues: { scope: myIssuesScope, filter: myIssuesFilter ?? {} } } + : { filter: listFilter }; // --- Drag state --- const [activeIssue, setActiveIssue] = useState(null); @@ -287,14 +300,14 @@ export function BoardView({ issueIds={columns[status] ?? []} issueMap={issueMapRef.current} childProgressMap={childProgressMap} - myIssuesOpts={myIssuesOpts} + loadMoreOptions={loadMoreOptions} /> ))} {hiddenStatuses.length > 0 && ( )}
@@ -315,17 +328,17 @@ function PaginatedBoardColumn({ issueIds, issueMap, childProgressMap, - myIssuesOpts, + loadMoreOptions, }: { status: IssueStatus; issueIds: string[]; issueMap: Map; childProgressMap?: Map; - myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; + loadMoreOptions: LoadMoreOptions; }) { const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus( status, - myIssuesOpts, + loadMoreOptions, ); return ( @@ -362,7 +375,7 @@ function HiddenColumnsPanel({ ))}
@@ -372,14 +385,14 @@ function HiddenColumnsPanel({ function HiddenColumnRow({ status, - myIssuesOpts, + loadMoreOptions, }: { status: IssueStatus; - myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; + loadMoreOptions: LoadMoreOptions; }) { const cfg = STATUS_CONFIG[status]; const viewStoreApi = useViewStoreApi(); - const { total } = useLoadMoreByStatus(status, myIssuesOpts); + const { total } = useLoadMoreByStatus(status, loadMoreOptions); return (
diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index 878b6e9482..c9e955d845 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -14,6 +14,7 @@ import { List, SignalHigh, SlidersHorizontal, + Tag, User, UserMinus, UserPen, @@ -49,8 +50,10 @@ import { useQuery } from "@tanstack/react-query"; import { useWorkspaceId } from "@multica/core/hooks"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; +import { labelListOptions } from "@multica/core/labels/queries"; import { ProjectIcon } from "../../projects/components/project-icon"; import { ActorAvatar } from "../../common/actor-avatar"; +import { LabelChip } from "../../labels/label-chip"; import { SORT_OPTIONS, CARD_PROPERTY_OPTIONS, @@ -94,6 +97,7 @@ function getActiveFilterCount(state: { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; + labelFilters: string[]; }) { let count = 0; if (state.statusFilters.length > 0) count++; @@ -101,6 +105,7 @@ function getActiveFilterCount(state: { if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++; if (state.creatorFilters.length > 0) count++; if (state.projectFilters.length > 0 || state.includeNoProject) count++; + if (state.labelFilters.length > 0) count++; return count; } @@ -111,6 +116,7 @@ function useIssueCounts(allIssues: Issue[]) { const assignee = new Map(); const creator = new Map(); const project = new Map(); + const label = new Map(); let noAssignee = 0; let noProject = 0; @@ -133,9 +139,15 @@ function useIssueCounts(allIssues: Issue[]) { } else { project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1); } + + if (issue.labels) { + for (const l of issue.labels) { + label.set(l.id, (label.get(l.id) ?? 0) + 1); + } + } } - return { status, priority, assignee, creator, noAssignee, project, noProject }; + return { status, priority, assignee, creator, noAssignee, project, noProject, label }; }, [allIssues]); } @@ -375,11 +387,75 @@ function ProjectSubContent({ ); } +// --------------------------------------------------------------------------- +// Label sub-menu content +// --------------------------------------------------------------------------- + +function LabelSubContent({ + counts, + selected, + onToggle, +}: { + counts: Map; + selected: string[]; + onToggle: (labelId: string) => void; +}) { + const [search, setSearch] = useState(""); + const wsId = useWorkspaceId(); + const { data: labels = [] } = useQuery(labelListOptions(wsId)); + const query = search.trim().toLowerCase(); + const filtered = labels.filter((l) => l.name.toLowerCase().includes(query)); + + return ( + <> +
+ setSearch(e.target.value)} + placeholder="Filter..." + className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" + autoFocus + /> +
+ +
+ {filtered.map((l) => { + const checked = selected.includes(l.id); + const count = counts.get(l.id) ?? 0; + return ( + onToggle(l.id)} + className={FILTER_ITEM_CLASS} + > + + + {count > 0 && ( + + {count} + + )} + + ); + })} + + {filtered.length === 0 && ( +
+ {search ? "No results" : "No labels yet"} +
+ )} +
+ + ); +} + // --------------------------------------------------------------------------- // IssuesHeader // --------------------------------------------------------------------------- -export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { +export function IssuesHeader({ issues }: { issues: Issue[] }) { const scope = useIssuesScopeStore((s) => s.scope); const setScope = useIssuesScopeStore((s) => s.setScope); @@ -391,12 +467,13 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { const creatorFilters = useViewStore((s) => s.creatorFilters); const projectFilters = useViewStore((s) => s.projectFilters); const includeNoProject = useViewStore((s) => s.includeNoProject); + const labelFilters = useViewStore((s) => s.labelFilters); const sortBy = useViewStore((s) => s.sortBy); const sortDirection = useViewStore((s) => s.sortDirection); const cardProperties = useViewStore((s) => s.cardProperties); const act = useViewStoreApi().getState(); - const counts = useIssueCounts(scopedIssues); + const counts = useIssueCounts(issues); const hasActiveFilters = getActiveFilterCount({ @@ -407,6 +484,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { creatorFilters, projectFilters, includeNoProject, + labelFilters, }) > 0; const sortLabel = @@ -600,6 +678,26 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { + {/* Label */} + + + + Label + {labelFilters.length > 0 && ( + + {labelFilters.length} + + )} + + + + + + {/* Reset */} {hasActiveFilters && ( <> diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index d543443c04..54db9e80b8 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -106,6 +106,7 @@ const mockViewState = { creatorFilters: [] as { type: string; id: string }[], projectFilters: [] as string[], includeNoProject: false, + labelFilters: [] as string[], sortBy: "position" as const, sortDirection: "asc" as const, cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true }, @@ -118,6 +119,7 @@ const mockViewState = { toggleCreatorFilter: vi.fn(), toggleProjectFilter: vi.fn(), toggleNoProject: vi.fn(), + toggleLabelFilter: vi.fn(), hideStatus: vi.fn(), showStatus: vi.fn(), clearFilters: vi.fn(), diff --git a/packages/views/issues/components/issues-page.tsx b/packages/views/issues/components/issues-page.tsx index dc2901b270..5f283800d5 100644 --- a/packages/views/issues/components/issues-page.tsx +++ b/packages/views/issues/components/issues-page.tsx @@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query"; import { useIssueViewStore, useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store"; import { useIssuesScopeStore } from "@multica/core/issues/stores/issues-scope-store"; import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context"; -import { filterIssues } from "../utils/filter"; +import { buildIssueListFilter } from "../utils/filter"; import { BOARD_STATUSES } from "@multica/core/issues/config"; import { useCurrentWorkspace } from "@multica/core/paths"; import { WorkspaceAvatar } from "../../workspace/workspace-avatar"; @@ -25,7 +25,6 @@ import { BatchActionToolbar } from "./batch-action-toolbar"; export function IssuesPage() { const wsId = useWorkspaceId(); - const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); const workspace = useCurrentWorkspace(); const scope = useIssuesScopeStore((s) => s.scope); @@ -37,6 +36,7 @@ export function IssuesPage() { const creatorFilters = useIssueViewStore((s) => s.creatorFilters); const projectFilters = useIssueViewStore((s) => s.projectFilters); const includeNoProject = useIssueViewStore((s) => s.includeNoProject); + const labelFilters = useIssueViewStore((s) => s.labelFilters); // Clear filter state when switching between workspaces (URL-driven). useClearFiltersOnWorkspaceChange(useIssueViewStore, wsId); @@ -45,18 +45,30 @@ export function IssuesPage() { useIssueSelectionStore.getState().clear(); }, [viewMode, scope]); - // Scope pre-filter: narrow by assignee type - const scopedIssues = useMemo(() => { - if (scope === "members") - return allIssues.filter((i) => i.assignee_type === "member"); - if (scope === "agents") - return allIssues.filter((i) => i.assignee_type === "agent"); - return allIssues; - }, [allIssues, scope]); - - const issues = useMemo( - () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject }), - [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject], + // Server-side filter: every active filter goes into the GET /api/issues + // query string so each status bucket fetches the *correct first 50*. With + // client-side filtering issues outside the first page silently fell out of + // view (#1491). Status is intentionally not part of the wire filter — each + // bucket fetches its own status, and we hide buckets via `visibleStatuses`. + // Scope (Members/Agents) is mapped to assignee_types so it routes through + // the same SQL filter — applying scope client-side reproduced the same + // pagination-blind bug for the scope tabs. + const listFilter = useMemo(() => { + const base = buildIssueListFilter({ + priorityFilters, + assigneeFilters, + includeNoAssignee, + creatorFilters, + projectFilters, + includeNoProject, + labelFilters, + }); + if (scope === "members") return { ...base, assignee_types: ["member" as const] }; + if (scope === "agents") return { ...base, assignee_types: ["agent" as const] }; + return base; + }, [scope, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters]); + const { data: issues = [], isLoading: loading } = useQuery( + issueListOptions(wsId, listFilter), ); // Fetch sub-issue progress from the backend so counts are accurate @@ -150,10 +162,10 @@ export function IssuesPage() { {/* Header 2: Scope tabs + filters */} - + {/* Content: scrollable */} - {scopedIssues.length === 0 ? ( + {issues.length === 0 ? (

No issues yet

@@ -168,9 +180,15 @@ export function IssuesPage() { hiddenStatuses={hiddenStatuses} onMoveIssue={handleMoveIssue} childProgressMap={childProgressMap} + listFilter={listFilter} /> ) : ( - + )}
)} diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx index 0cef6e4ce3..81b47b194c 100644 --- a/packages/views/issues/components/list-view.tsx +++ b/packages/views/issues/components/list-view.tsx @@ -7,7 +7,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ import { Button } from "@multica/ui/components/ui/button"; import type { Issue, IssueStatus } from "@multica/core/types"; import { useLoadMoreByStatus } from "@multica/core/issues/mutations"; -import type { MyIssuesFilter } from "@multica/core/issues/queries"; +import type { IssueListFilter, MyIssuesFilter } from "@multica/core/issues/queries"; import { STATUS_CONFIG } from "@multica/core/issues/config"; import { useModalStore } from "@multica/core/modals"; import { useViewStore } from "@multica/core/issues/stores/view-store-context"; @@ -19,16 +19,27 @@ import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; const EMPTY_PROGRESS_MAP = new Map(); +/** + * Threaded into useLoadMoreByStatus — keeps pagination requests on the same + * filter (or My Issues scope) as the page is currently rendering. + */ +type LoadMoreOptions = + | { filter?: IssueListFilter; myIssues?: never } + | { filter?: never; myIssues: { scope: string; filter: MyIssuesFilter } }; + export function ListView({ issues, visibleStatuses, childProgressMap = EMPTY_PROGRESS_MAP, + listFilter, myIssuesScope, myIssuesFilter, }: { issues: Issue[]; visibleStatuses: IssueStatus[]; childProgressMap?: Map; + /** Workspace list filter — pagination targets the matching filter cache. */ + listFilter?: IssueListFilter; /** When set, per-status load-more targets the scoped cache instead of the workspace one. */ myIssuesScope?: string; myIssuesFilter?: MyIssuesFilter; @@ -59,9 +70,9 @@ export function ListView({ [visibleStatuses, listCollapsedStatuses] ); - const myIssuesOpts = myIssuesScope - ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } - : undefined; + const loadMoreOptions: LoadMoreOptions = myIssuesScope + ? { myIssues: { scope: myIssuesScope, filter: myIssuesFilter ?? {} } } + : { filter: listFilter }; return (
@@ -85,7 +96,7 @@ export function ListView({ status={status} issues={issuesByStatus.get(status) ?? []} childProgressMap={childProgressMap} - myIssuesOpts={myIssuesOpts} + loadMoreOptions={loadMoreOptions} /> ))} @@ -97,12 +108,12 @@ function StatusAccordionItem({ status, issues, childProgressMap, - myIssuesOpts, + loadMoreOptions, }: { status: IssueStatus; issues: Issue[]; childProgressMap: Map; - myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; + loadMoreOptions: LoadMoreOptions; }) { const cfg = STATUS_CONFIG[status]; const selectedIds = useIssueSelectionStore((s) => s.selectedIds); @@ -110,7 +121,7 @@ function StatusAccordionItem({ const deselect = useIssueSelectionStore((s) => s.deselect); const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus( status, - myIssuesOpts, + loadMoreOptions, ); const issueIds = issues.map((i) => i.id); diff --git a/packages/views/issues/utils/filter.test.ts b/packages/views/issues/utils/filter.test.ts index adbb4faae7..e93be40695 100644 --- a/packages/views/issues/utils/filter.test.ts +++ b/packages/views/issues/utils/filter.test.ts @@ -1,164 +1,93 @@ import { describe, it, expect } from "vitest"; -import type { Issue } from "@multica/core/types"; -import { filterIssues, type IssueFilters } from "./filter"; +import { buildIssueListFilter, type IssueViewFilters } from "./filter"; -const NO_FILTER: IssueFilters = { - statusFilters: [], +const NO_FILTER: IssueViewFilters = { priorityFilters: [], assigneeFilters: [], includeNoAssignee: false, creatorFilters: [], projectFilters: [], includeNoProject: false, + labelFilters: [], }; -function makeIssue(overrides: Partial = {}): Issue { - return { - id: "i-1", - workspace_id: "ws-1", - number: 1, - identifier: "MUL-1", - title: "Test", - description: null, - status: "todo", - priority: "medium", - assignee_type: null, - assignee_id: null, - creator_type: "member", - creator_id: "u-1", - parent_issue_id: null, - project_id: null, - position: 0, - due_date: null, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - ...overrides, - }; -} - -const issues: Issue[] = [ - makeIssue({ id: "1", status: "todo", priority: "high", assignee_type: "member", assignee_id: "u-1", creator_type: "member", creator_id: "u-1", project_id: "p-1" }), - makeIssue({ id: "2", status: "in_progress", priority: "medium", assignee_type: "agent", assignee_id: "a-1", creator_type: "agent", creator_id: "a-1", project_id: "p-2" }), - makeIssue({ id: "3", status: "done", priority: "low", assignee_type: null, assignee_id: null, creator_type: "member", creator_id: "u-2", project_id: null }), - makeIssue({ id: "4", status: "todo", priority: "urgent", assignee_type: "member", assignee_id: "u-2", creator_type: "member", creator_id: "u-1", project_id: "p-1" }), -]; - -describe("filterIssues", () => { - it("returns all issues when no filters are active", () => { - expect(filterIssues(issues, NO_FILTER)).toHaveLength(4); +describe("buildIssueListFilter", () => { + it("returns an empty object when nothing is selected", () => { + expect(buildIssueListFilter(NO_FILTER)).toEqual({}); }); - // --- Status --- - it("filters by status", () => { - const result = filterIssues(issues, { ...NO_FILTER, statusFilters: ["todo"] }); - expect(result.map((i) => i.id)).toEqual(["1", "4"]); + it("encodes priority filter as an array", () => { + expect( + buildIssueListFilter({ ...NO_FILTER, priorityFilters: ["high", "urgent"] }), + ).toEqual({ priorities: ["high", "urgent"] }); }); - // --- Priority --- - it("filters by priority", () => { - const result = filterIssues(issues, { ...NO_FILTER, priorityFilters: ["high", "urgent"] }); - expect(result.map((i) => i.id)).toEqual(["1", "4"]); + it("encodes assignee filters as id-only arrays", () => { + expect( + buildIssueListFilter({ + ...NO_FILTER, + assigneeFilters: [ + { type: "member", id: "u-1" }, + { type: "agent", id: "a-1" }, + ], + }), + ).toEqual({ assignee_ids: ["u-1", "a-1"] }); }); - // --- Assignee --- - it("filters by specific assignee", () => { - const result = filterIssues(issues, { - ...NO_FILTER, - assigneeFilters: [{ type: "member", id: "u-1" }], + it("encodes 'no assignee' as include_no_assignee", () => { + expect(buildIssueListFilter({ ...NO_FILTER, includeNoAssignee: true })).toEqual({ + include_no_assignee: true, }); - expect(result.map((i) => i.id)).toEqual(["1"]); }); - it("filters by 'No assignee' only", () => { - const result = filterIssues(issues, { ...NO_FILTER, includeNoAssignee: true }); - expect(result.map((i) => i.id)).toEqual(["3"]); + it("combines assignee ids with the no-assignee toggle", () => { + expect( + buildIssueListFilter({ + ...NO_FILTER, + assigneeFilters: [{ type: "member", id: "u-1" }], + includeNoAssignee: true, + }), + ).toEqual({ assignee_ids: ["u-1"], include_no_assignee: true }); }); - it("filters by assignee + No assignee combined", () => { - const result = filterIssues(issues, { - ...NO_FILTER, - assigneeFilters: [{ type: "agent", id: "a-1" }], - includeNoAssignee: true, + it("encodes creator and project filters", () => { + expect( + buildIssueListFilter({ + ...NO_FILTER, + creatorFilters: [{ type: "member", id: "u-2" }], + projectFilters: ["p-1", "p-2"], + includeNoProject: true, + }), + ).toEqual({ + creator_ids: ["u-2"], + project_ids: ["p-1", "p-2"], + include_no_project: true, }); - expect(result.map((i) => i.id)).toEqual(["2", "3"]); }); - it("hides assigned issues when only 'No assignee' is selected", () => { - const result = filterIssues(issues, { ...NO_FILTER, includeNoAssignee: true }); - expect(result.every((i) => !i.assignee_id)).toBe(true); + it("encodes label filters", () => { + expect( + buildIssueListFilter({ ...NO_FILTER, labelFilters: ["l-1", "l-2"] }), + ).toEqual({ label_ids: ["l-1", "l-2"] }); }); - // --- Creator --- - it("filters by creator", () => { - const result = filterIssues(issues, { - ...NO_FILTER, - creatorFilters: [{ type: "agent", id: "a-1" }], + it("combines every filter dimension", () => { + expect( + buildIssueListFilter({ + priorityFilters: ["high"], + assigneeFilters: [{ type: "member", id: "u-1" }], + includeNoAssignee: false, + creatorFilters: [{ type: "agent", id: "a-1" }], + projectFilters: ["p-1"], + includeNoProject: false, + labelFilters: ["l-1"], + }), + ).toEqual({ + priorities: ["high"], + assignee_ids: ["u-1"], + creator_ids: ["a-1"], + project_ids: ["p-1"], + label_ids: ["l-1"], }); - expect(result.map((i) => i.id)).toEqual(["2"]); - }); - - // --- Combinations --- - it("applies status + assignee filters together", () => { - const result = filterIssues(issues, { - ...NO_FILTER, - statusFilters: ["todo"], - assigneeFilters: [{ type: "member", id: "u-1" }], - }); - expect(result.map((i) => i.id)).toEqual(["1"]); - }); - - it("applies status + priority + creator filters together", () => { - const result = filterIssues(issues, { - ...NO_FILTER, - statusFilters: ["todo"], - priorityFilters: ["urgent"], - creatorFilters: [{ type: "member", id: "u-1" }], - }); - expect(result.map((i) => i.id)).toEqual(["4"]); - }); - - // --- Project --- - it("filters by specific project", () => { - const result = filterIssues(issues, { - ...NO_FILTER, - projectFilters: ["p-1"], - }); - expect(result.map((i) => i.id)).toEqual(["1", "4"]); - }); - - it("filters by multiple projects", () => { - const result = filterIssues(issues, { - ...NO_FILTER, - projectFilters: ["p-1", "p-2"], - }); - expect(result.map((i) => i.id)).toEqual(["1", "2", "4"]); - }); - - it("filters by 'No project' only", () => { - const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true }); - expect(result.map((i) => i.id)).toEqual(["3"]); - }); - - it("filters by project + No project combined", () => { - const result = filterIssues(issues, { - ...NO_FILTER, - projectFilters: ["p-2"], - includeNoProject: true, - }); - expect(result.map((i) => i.id)).toEqual(["2", "3"]); - }); - - it("hides project issues when only 'No project' is selected", () => { - const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true }); - expect(result.every((i) => !i.project_id)).toBe(true); - }); - - it("applies status + project filters together", () => { - const result = filterIssues(issues, { - ...NO_FILTER, - statusFilters: ["todo"], - projectFilters: ["p-1"], - }); - expect(result.map((i) => i.id)).toEqual(["1", "4"]); }); }); diff --git a/packages/views/issues/utils/filter.ts b/packages/views/issues/utils/filter.ts index 7779318ce9..3d5c164f1d 100644 --- a/packages/views/issues/utils/filter.ts +++ b/packages/views/issues/utils/filter.ts @@ -1,72 +1,57 @@ -import type { Issue, IssueStatus, IssuePriority } from "@multica/core/types"; +import type { IssuePriority } from "@multica/core/types"; +import type { IssueListFilter } from "@multica/core/issues/queries"; import type { ActorFilterValue } from "@multica/core/issues/stores/view-store"; -export interface IssueFilters { - statusFilters: IssueStatus[]; +/** + * Shape of the filter state held in the view store. Status is excluded — it + * controls which buckets are visible on the page, not which buckets are + * fetched (see {@link buildIssueListFilter} below). + */ +export interface IssueViewFilters { priorityFilters: IssuePriority[]; assigneeFilters: ActorFilterValue[]; includeNoAssignee: boolean; creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; + labelFilters: string[]; } /** - * Filter issues using positive selection model. - * Empty arrays = no filter (show all). Non-empty = show only matching. + * Translate the view-store filter state into the wire-shape filter accepted + * by `GET /api/issues`. Each filter type is OR'd within itself and AND'd + * with the others — same semantics the SQL layer enforces. * - * Assignee has a special "No assignee" toggle (includeNoAssignee): - * - When only includeNoAssignee is true → show only unassigned issues - * - When assigneeFilters has items → show only those assignees' issues - * - When both → show matching assignees + unassigned + * Assignee and project filters can mix actor IDs with the "no assignee / + * project" toggle. The toggle is encoded as `include_no_*: true`, which the + * backend OR's against the id-list match. + * + * Returns an empty object when nothing is selected — callers can pass this + * to {@link issueListOptions} as a no-op filter. */ -export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] { - const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject } = filters; - const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee; - const hasProjectFilter = projectFilters.length > 0 || includeNoProject; - - return issues.filter((issue) => { - if (statusFilters.length > 0 && !statusFilters.includes(issue.status)) - return false; - - if (priorityFilters.length > 0 && !priorityFilters.includes(issue.priority)) - return false; - - if (hasAssigneeFilter) { - if (!issue.assignee_id) { - // Unassigned issue — show only if "No assignee" is checked - if (!includeNoAssignee) return false; - } else if (assigneeFilters.length > 0) { - // Assigned issue — show only if assignee is in the filter list - if (!assigneeFilters.some( - (f) => f.type === issue.assignee_type && f.id === issue.assignee_id, - )) return false; - } else { - // Only "No assignee" is checked, no specific assignees → hide assigned issues - return false; - } - } - - if ( - creatorFilters.length > 0 && - !creatorFilters.some( - (f) => f.type === issue.creator_type && f.id === issue.creator_id, - ) - ) { - return false; - } - - if (hasProjectFilter) { - if (!issue.project_id) { - if (!includeNoProject) return false; - } else if (projectFilters.length > 0) { - if (!projectFilters.includes(issue.project_id)) return false; - } else { - // Only "No project" is checked → hide issues that have a project - return false; - } - } - - return true; - }); +export function buildIssueListFilter( + filters: IssueViewFilters, +): IssueListFilter { + const out: IssueListFilter = {}; + if (filters.priorityFilters.length > 0) { + out.priorities = [...filters.priorityFilters]; + } + // Assignee + creator filters carry an actor type alongside the id, but the + // backend keys assignment by id alone (member/agent ids never collide), so + // we send only the ids. + if (filters.assigneeFilters.length > 0) { + out.assignee_ids = filters.assigneeFilters.map((f) => f.id); + } + if (filters.includeNoAssignee) out.include_no_assignee = true; + if (filters.creatorFilters.length > 0) { + out.creator_ids = filters.creatorFilters.map((f) => f.id); + } + if (filters.projectFilters.length > 0) { + out.project_ids = [...filters.projectFilters]; + } + if (filters.includeNoProject) out.include_no_project = true; + if (filters.labelFilters.length > 0) { + out.label_ids = [...filters.labelFilters]; + } + return out; } diff --git a/packages/views/my-issues/components/my-issues-page.tsx b/packages/views/my-issues/components/my-issues-page.tsx index 03c9b2af13..546700720a 100644 --- a/packages/views/my-issues/components/my-issues-page.tsx +++ b/packages/views/my-issues/components/my-issues-page.tsx @@ -11,7 +11,6 @@ import { useCurrentWorkspace } from "@multica/core/paths"; import { WorkspaceAvatar } from "../../workspace/workspace-avatar"; import { useQuery } from "@tanstack/react-query"; import { agentListOptions } from "@multica/core/workspace/queries"; -import { filterIssues } from "../../issues/utils/filter"; import { BOARD_STATUSES } from "@multica/core/issues/config"; import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context"; import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store"; @@ -55,36 +54,38 @@ export function MyIssuesPage() { const filter: MyIssuesFilter = useMemo(() => { if (!user) return {}; + // Server-side filtering — priority filters are added so paginated buckets + // reflect the user's selection even when matches sit past the first page. + // Status filtering stays client-side via `visibleStatuses`: each status + // bucket is fetched independently anyway, so hiding columns is a render + // concern, not a data-fetching one. + const base: MyIssuesFilter = + priorityFilters.length > 0 ? { priorities: [...priorityFilters] } : {}; switch (scope) { case "assigned": - return { assignee_id: user.id }; + return { ...base, assignee_ids: [user.id] }; case "created": - return { creator_id: user.id }; + return { ...base, creator_ids: [user.id] }; case "agents": - return { assignee_ids: myAgentIds }; + return { ...base, assignee_ids: myAgentIds }; default: - return { assignee_id: user.id }; + return { ...base, assignee_ids: [user.id] }; } - }, [scope, user, myAgentIds]); + }, [scope, user, myAgentIds, priorityFilters]); - const { data: myIssues = [], isLoading: loading } = useQuery( - myIssueListOptions(wsId, scope, filter), - ); - - // Apply status/priority filters from view store - const issues = useMemo( - () => - filterIssues(myIssues, { - statusFilters, - priorityFilters, - assigneeFilters: [], - includeNoAssignee: false, - creatorFilters: [], - projectFilters: [], - includeNoProject: false, - }), - [myIssues, statusFilters, priorityFilters], - ); + // The "My Agents" tab is empty by definition when the user owns no agents. + // Without this guard the filter would resolve to `assignee_ids: []`, which + // the API client and query-key normalizer drop as falsy — the request would + // then go out unfiltered and surface other people's issues under "My Agents". + const myAgentsEmpty = scope === "agents" && myAgentIds.length === 0; + const { data: myIssues = [], isLoading: loading } = useQuery({ + ...myIssueListOptions(wsId, scope, filter), + enabled: !myAgentsEmpty, + }); + // Server-side filtering means `myIssues` already reflects the active scope + // + priority filter; rendering this directly avoids the pagination bug + // that the old client-side `filterIssues` step caused. + const issues = myIssues; const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId)); diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index 24db446fb4..08d2be818e 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState, useCallback, useRef, useEffect } from "react"; +import { useStore } from "zustand"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, PanelRight, Pin, PinOff, Trash2, UserMinus } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; @@ -22,7 +23,7 @@ import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG, PROJECT_PRIORITY_ORDER, PR import { BOARD_STATUSES } from "@multica/core/issues/config"; import { createIssueViewStore } from "@multica/core/issues/stores/view-store"; import { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/view-store-context"; -import { filterIssues } from "../../issues/utils/filter"; +import { buildIssueListFilter } from "../../issues/utils/filter"; import { getProjectIssueMetrics } from "./project-issue-metrics"; import { ActorAvatar } from "../../common/actor-avatar"; import { AppLink, useNavigation } from "../../navigation"; @@ -99,6 +100,7 @@ function ProjectIssuesContent({ scope, filter, }: { + /** Issues already fetched server-side with this project + view-store filter applied. */ projectIssues: Issue[]; scope: string; filter: MyIssuesFilter; @@ -106,15 +108,12 @@ function ProjectIssuesContent({ const wsId = useWorkspaceId(); const viewMode = useViewStore((s) => s.viewMode); const statusFilters = useViewStore((s) => s.statusFilters); - const priorityFilters = useViewStore((s) => s.priorityFilters); - const assigneeFilters = useViewStore((s) => s.assigneeFilters); - const includeNoAssignee = useViewStore((s) => s.includeNoAssignee); - const creatorFilters = useViewStore((s) => s.creatorFilters); - const issues = useMemo( - () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }), - [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], - ); + // Server-side filtering means `projectIssues` already reflects priority / + // assignee / creator / label filters. Status filtering remains a render + // concern (each status bucket is fetched independently anyway, see + // visibleStatuses below). + const issues = projectIssues; const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId)); @@ -195,9 +194,32 @@ export function ProjectDetail({ projectId }: { projectId: string }) { const workspaceName = workspace?.name; const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId)); const projectScope = `project:${projectId}`; + // Read view-store filter state directly from the project's own view store + // (lives outside the ViewStoreProvider here). Merging it into the query + // filter means filter changes trigger a refetch under a fresh cache key + // — fixing the pagination bug where client-side filtering hid matches + // sitting past the first page (#1491). + const priorityFilters = useStore(projectViewStore, (s) => s.priorityFilters); + const assigneeFilters = useStore(projectViewStore, (s) => s.assigneeFilters); + const includeNoAssignee = useStore(projectViewStore, (s) => s.includeNoAssignee); + const creatorFilters = useStore(projectViewStore, (s) => s.creatorFilters); + const labelFilters = useStore(projectViewStore, (s) => s.labelFilters); const projectFilter = useMemo( - () => ({ project_id: projectId }), - [projectId], + () => ({ + project_ids: [projectId], + ...buildIssueListFilter({ + priorityFilters, + assigneeFilters, + includeNoAssignee, + creatorFilters, + // Project filter is fixed by the page route — view-store project + // selections don't apply here. + projectFilters: [], + includeNoProject: false, + labelFilters, + }), + }), + [projectId, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters], ); const { data: projectIssues = [] } = useQuery( myIssueListOptions(wsId, projectScope, projectFilter), @@ -579,7 +601,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) { - + 0 { + params.Set("label_ids", strings.Join(labels, ",")) + } path := "/api/issues" if len(params) > 0 { diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index 039e3cce26..6634ee4bee 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -8,6 +8,7 @@ import ( "errors" "log/slog" "net/http" + "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" @@ -153,7 +154,14 @@ func parseUUIDOrBadRequest(w http.ResponseWriter, s, fieldName string) (pgtype.U return u, true } +// parseUUIDSliceOrBadRequest mirrors parseUUIDOrBadRequest for slice inputs. +// An empty input slice returns nil (not a zero-length slice) so callers can +// distinguish "not provided" from "explicitly empty" — both look the same on +// the wire, but the SQL-layer narg check expects NULL for "no filter". func parseUUIDSliceOrBadRequest(w http.ResponseWriter, ids []string, fieldName string) ([]pgtype.UUID, bool) { + if len(ids) == 0 { + return nil, true + } uuids := make([]pgtype.UUID, len(ids)) for i, id := range ids { u, err := util.ParseUUID(id) @@ -166,6 +174,26 @@ func parseUUIDSliceOrBadRequest(w http.ResponseWriter, ids []string, fieldName s return uuids, true } +// splitCSV parses a comma-separated query-string value into a slice, trimming +// whitespace and dropping empty entries. Returns nil for an empty input so +// callers can distinguish "absent" from "present but empty". +func splitCSV(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + if len(out) == 0 { + return nil + } + return out +} + // publish sends a domain event through the event bus. func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) { h.Bus.Publish(events.Event{ diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 10eee7d73d..55d433c6e7 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -609,56 +609,75 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { // Parse optional filter params. Malformed UUIDs in filters return 400 — // silently coercing them to a zero UUID would mask a client bug and let // the query return an empty result set (or worse, match a NULL row). - var priorityFilter pgtype.Text - if p := r.URL.Query().Get("priority"); p != "" { - priorityFilter = pgtype.Text{String: p, Valid: true} - } - var assigneeFilter pgtype.UUID - if a := r.URL.Query().Get("assignee_id"); a != "" { - id, ok := parseUUIDOrBadRequest(w, a, "assignee_id") - if !ok { - return + q := r.URL.Query() + + priorities := splitCSV(q.Get("priorities")) + if priorities == nil { + // Backwards compat: accept legacy single-value `priority`. + if p := q.Get("priority"); p != "" { + priorities = []string{p} } - assigneeFilter = id } - var assigneeIdsFilter []pgtype.UUID - if ids := r.URL.Query().Get("assignee_ids"); ids != "" { - for _, raw := range strings.Split(ids, ",") { - if s := strings.TrimSpace(raw); s != "" { - id, ok := parseUUIDOrBadRequest(w, s, "assignee_ids") - if !ok { - return - } - assigneeIdsFilter = append(assigneeIdsFilter, id) + assigneeTypes := splitCSV(q.Get("assignee_types")) + assigneeIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("assignee_ids")), "assignee_ids") + if !ok { + return + } + if assigneeIds == nil { + // Backwards compat: accept legacy single-value `assignee_id`. + if a := q.Get("assignee_id"); a != "" { + id, ok := parseUUIDOrBadRequest(w, a, "assignee_id") + if !ok { + return } + assigneeIds = []pgtype.UUID{id} } } - var creatorFilter pgtype.UUID - if c := r.URL.Query().Get("creator_id"); c != "" { - id, ok := parseUUIDOrBadRequest(w, c, "creator_id") - if !ok { - return - } - creatorFilter = id + creatorIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("creator_ids")), "creator_ids") + if !ok { + return } - var projectFilter pgtype.UUID - if p := r.URL.Query().Get("project_id"); p != "" { - id, ok := parseUUIDOrBadRequest(w, p, "project_id") - if !ok { - return + if creatorIds == nil { + if c := q.Get("creator_id"); c != "" { + id, ok := parseUUIDOrBadRequest(w, c, "creator_id") + if !ok { + return + } + creatorIds = []pgtype.UUID{id} } - projectFilter = id } + projectIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("project_ids")), "project_ids") + if !ok { + return + } + if projectIds == nil { + if p := q.Get("project_id"); p != "" { + id, ok := parseUUIDOrBadRequest(w, p, "project_id") + if !ok { + return + } + projectIds = []pgtype.UUID{id} + } + } + labelIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("label_ids")), "label_ids") + if !ok { + return + } + includeNoAssignee := pgtype.Bool{Bool: q.Get("include_no_assignee") == "true", Valid: true} + includeNoProject := pgtype.Bool{Bool: q.Get("include_no_project") == "true", Valid: true} // open_only=true returns all non-done/cancelled issues (no limit). - if r.URL.Query().Get("open_only") == "true" { + if q.Get("open_only") == "true" { issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{ - WorkspaceID: wsUUID, - Priority: priorityFilter, - AssigneeID: assigneeFilter, - AssigneeIds: assigneeIdsFilter, - CreatorID: creatorFilter, - ProjectID: projectFilter, + WorkspaceID: wsUUID, + Priorities: priorities, + AssigneeTypes: assigneeTypes, + AssigneeIds: assigneeIds, + IncludeNoAssignee: includeNoAssignee, + CreatorIds: creatorIds, + ProjectIds: projectIds, + IncludeNoProject: includeNoProject, + LabelIds: labelIds, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -690,32 +709,35 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { limit := 100 offset := 0 - if l := r.URL.Query().Get("limit"); l != "" { + if l := q.Get("limit"); l != "" { if v, err := strconv.Atoi(l); err == nil { limit = v } } - if o := r.URL.Query().Get("offset"); o != "" { + if o := q.Get("offset"); o != "" { if v, err := strconv.Atoi(o); err == nil { offset = v } } var statusFilter pgtype.Text - if s := r.URL.Query().Get("status"); s != "" { + if s := q.Get("status"); s != "" { statusFilter = pgtype.Text{String: s, Valid: true} } issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ - WorkspaceID: wsUUID, - Limit: int32(limit), - Offset: int32(offset), - Status: statusFilter, - Priority: priorityFilter, - AssigneeID: assigneeFilter, - AssigneeIds: assigneeIdsFilter, - CreatorID: creatorFilter, - ProjectID: projectFilter, + WorkspaceID: wsUUID, + Limit: int32(limit), + Offset: int32(offset), + Status: statusFilter, + Priorities: priorities, + AssigneeTypes: assigneeTypes, + AssigneeIds: assigneeIds, + IncludeNoAssignee: includeNoAssignee, + CreatorIds: creatorIds, + ProjectIds: projectIds, + IncludeNoProject: includeNoProject, + LabelIds: labelIds, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -724,13 +746,16 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { // Get the true total count for pagination awareness. total, err := h.Queries.CountIssues(ctx, db.CountIssuesParams{ - WorkspaceID: wsUUID, - Status: statusFilter, - Priority: priorityFilter, - AssigneeID: assigneeFilter, - AssigneeIds: assigneeIdsFilter, - CreatorID: creatorFilter, - ProjectID: projectFilter, + WorkspaceID: wsUUID, + Status: statusFilter, + Priorities: priorities, + AssigneeTypes: assigneeTypes, + AssigneeIds: assigneeIds, + IncludeNoAssignee: includeNoAssignee, + CreatorIds: creatorIds, + ProjectIds: projectIds, + IncludeNoProject: includeNoProject, + LabelIds: labelIds, }) if err != nil { total = int64(len(issues)) diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index d33dc7eb41..cf59365553 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -97,32 +97,51 @@ const countIssues = `-- name: CountIssues :one SELECT count(*) FROM issue WHERE workspace_id = $1 AND ($2::text IS NULL OR status = $2) - AND ($3::text IS NULL OR priority = $3) - AND ($4::uuid IS NULL OR assignee_id = $4) - AND ($5::uuid[] IS NULL OR assignee_id = ANY($5::uuid[])) - AND ($6::uuid IS NULL OR creator_id = $6) - AND ($7::uuid IS NULL OR project_id = $7) + AND ($3::text[] IS NULL OR priority = ANY($3::text[])) + AND ($4::text[] IS NULL OR assignee_type = ANY($4::text[])) + AND ( + ($5::uuid[] IS NULL AND NOT COALESCE($6::bool, false)) + OR assignee_id = ANY($5::uuid[]) + OR (COALESCE($6::bool, false) AND assignee_id IS NULL) + ) + AND ($7::uuid[] IS NULL OR creator_id = ANY($7::uuid[])) + AND ( + ($8::uuid[] IS NULL AND NOT COALESCE($9::bool, false)) + OR project_id = ANY($8::uuid[]) + OR (COALESCE($9::bool, false) AND project_id IS NULL) + ) + AND ($10::uuid[] IS NULL OR EXISTS ( + SELECT 1 FROM issue_to_label il + WHERE il.issue_id = issue.id + AND il.label_id = ANY($10::uuid[]) + )) ` type CountIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Status pgtype.Text `json:"status"` - Priority pgtype.Text `json:"priority"` - AssigneeID pgtype.UUID `json:"assignee_id"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - CreatorID pgtype.UUID `json:"creator_id"` - ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Status pgtype.Text `json:"status"` + Priorities []string `json:"priorities"` + AssigneeTypes []string `json:"assignee_types"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + IncludeNoAssignee pgtype.Bool `json:"include_no_assignee"` + CreatorIds []pgtype.UUID `json:"creator_ids"` + ProjectIds []pgtype.UUID `json:"project_ids"` + IncludeNoProject pgtype.Bool `json:"include_no_project"` + LabelIds []pgtype.UUID `json:"label_ids"` } func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64, error) { row := q.db.QueryRow(ctx, countIssues, arg.WorkspaceID, arg.Status, - arg.Priority, - arg.AssigneeID, + arg.Priorities, + arg.AssigneeTypes, arg.AssigneeIds, - arg.CreatorID, - arg.ProjectID, + arg.IncludeNoAssignee, + arg.CreatorIds, + arg.ProjectIds, + arg.IncludeNoProject, + arg.LabelIds, ) var count int64 err := row.Scan(&count) @@ -459,25 +478,41 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND ($4::text IS NULL OR status = $4) - AND ($5::text IS NULL OR priority = $5) - AND ($6::uuid IS NULL OR assignee_id = $6) - AND ($7::uuid[] IS NULL OR assignee_id = ANY($7::uuid[])) - AND ($8::uuid IS NULL OR creator_id = $8) - AND ($9::uuid IS NULL OR project_id = $9) + AND ($5::text[] IS NULL OR priority = ANY($5::text[])) + AND ($6::text[] IS NULL OR assignee_type = ANY($6::text[])) + AND ( + ($7::uuid[] IS NULL AND NOT COALESCE($8::bool, false)) + OR assignee_id = ANY($7::uuid[]) + OR (COALESCE($8::bool, false) AND assignee_id IS NULL) + ) + AND ($9::uuid[] IS NULL OR creator_id = ANY($9::uuid[])) + AND ( + ($10::uuid[] IS NULL AND NOT COALESCE($11::bool, false)) + OR project_id = ANY($10::uuid[]) + OR (COALESCE($11::bool, false) AND project_id IS NULL) + ) + AND ($12::uuid[] IS NULL OR EXISTS ( + SELECT 1 FROM issue_to_label il + WHERE il.issue_id = issue.id + AND il.label_id = ANY($12::uuid[]) + )) ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3 ` type ListIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` - Status pgtype.Text `json:"status"` - Priority pgtype.Text `json:"priority"` - AssigneeID pgtype.UUID `json:"assignee_id"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - CreatorID pgtype.UUID `json:"creator_id"` - ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + Status pgtype.Text `json:"status"` + Priorities []string `json:"priorities"` + AssigneeTypes []string `json:"assignee_types"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + IncludeNoAssignee pgtype.Bool `json:"include_no_assignee"` + CreatorIds []pgtype.UUID `json:"creator_ids"` + ProjectIds []pgtype.UUID `json:"project_ids"` + IncludeNoProject pgtype.Bool `json:"include_no_project"` + LabelIds []pgtype.UUID `json:"label_ids"` } type ListIssuesRow struct { @@ -506,11 +541,14 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI arg.Limit, arg.Offset, arg.Status, - arg.Priority, - arg.AssigneeID, + arg.Priorities, + arg.AssigneeTypes, arg.AssigneeIds, - arg.CreatorID, - arg.ProjectID, + arg.IncludeNoAssignee, + arg.CreatorIds, + arg.ProjectIds, + arg.IncludeNoProject, + arg.LabelIds, ) if err != nil { return nil, err @@ -555,21 +593,37 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') - AND ($2::text IS NULL OR priority = $2) - AND ($3::uuid IS NULL OR assignee_id = $3) - AND ($4::uuid[] IS NULL OR assignee_id = ANY($4::uuid[])) - AND ($5::uuid IS NULL OR creator_id = $5) - AND ($6::uuid IS NULL OR project_id = $6) + AND ($2::text[] IS NULL OR priority = ANY($2::text[])) + AND ($3::text[] IS NULL OR assignee_type = ANY($3::text[])) + AND ( + ($4::uuid[] IS NULL AND NOT COALESCE($5::bool, false)) + OR assignee_id = ANY($4::uuid[]) + OR (COALESCE($5::bool, false) AND assignee_id IS NULL) + ) + AND ($6::uuid[] IS NULL OR creator_id = ANY($6::uuid[])) + AND ( + ($7::uuid[] IS NULL AND NOT COALESCE($8::bool, false)) + OR project_id = ANY($7::uuid[]) + OR (COALESCE($8::bool, false) AND project_id IS NULL) + ) + AND ($9::uuid[] IS NULL OR EXISTS ( + SELECT 1 FROM issue_to_label il + WHERE il.issue_id = issue.id + AND il.label_id = ANY($9::uuid[]) + )) ORDER BY position ASC, created_at DESC ` type ListOpenIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Priority pgtype.Text `json:"priority"` - AssigneeID pgtype.UUID `json:"assignee_id"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - CreatorID pgtype.UUID `json:"creator_id"` - ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Priorities []string `json:"priorities"` + AssigneeTypes []string `json:"assignee_types"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + IncludeNoAssignee pgtype.Bool `json:"include_no_assignee"` + CreatorIds []pgtype.UUID `json:"creator_ids"` + ProjectIds []pgtype.UUID `json:"project_ids"` + IncludeNoProject pgtype.Bool `json:"include_no_project"` + LabelIds []pgtype.UUID `json:"label_ids"` } type ListOpenIssuesRow struct { @@ -595,11 +649,14 @@ type ListOpenIssuesRow struct { func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) { rows, err := q.db.Query(ctx, listOpenIssues, arg.WorkspaceID, - arg.Priority, - arg.AssigneeID, + arg.Priorities, + arg.AssigneeTypes, arg.AssigneeIds, - arg.CreatorID, - arg.ProjectID, + arg.IncludeNoAssignee, + arg.CreatorIds, + arg.ProjectIds, + arg.IncludeNoProject, + arg.LabelIds, ) if err != nil { return nil, err diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 617e7fab84..26308b8cbe 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -5,11 +5,24 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) - AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) - AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) - AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) - AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) - AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) + AND (sqlc.narg('priorities')::text[] IS NULL OR priority = ANY(sqlc.narg('priorities')::text[])) + AND (sqlc.narg('assignee_types')::text[] IS NULL OR assignee_type = ANY(sqlc.narg('assignee_types')::text[])) + AND ( + (sqlc.narg('assignee_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_assignee')::bool, false)) + OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]) + OR (COALESCE(sqlc.narg('include_no_assignee')::bool, false) AND assignee_id IS NULL) + ) + AND (sqlc.narg('creator_ids')::uuid[] IS NULL OR creator_id = ANY(sqlc.narg('creator_ids')::uuid[])) + AND ( + (sqlc.narg('project_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_project')::bool, false)) + OR project_id = ANY(sqlc.narg('project_ids')::uuid[]) + OR (COALESCE(sqlc.narg('include_no_project')::bool, false) AND project_id IS NULL) + ) + AND (sqlc.narg('label_ids')::uuid[] IS NULL OR EXISTS ( + SELECT 1 FROM issue_to_label il + WHERE il.issue_id = issue.id + AND il.label_id = ANY(sqlc.narg('label_ids')::uuid[]) + )) ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3; @@ -78,22 +91,48 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') - AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) - AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) - AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) - AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) - AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) + AND (sqlc.narg('priorities')::text[] IS NULL OR priority = ANY(sqlc.narg('priorities')::text[])) + AND (sqlc.narg('assignee_types')::text[] IS NULL OR assignee_type = ANY(sqlc.narg('assignee_types')::text[])) + AND ( + (sqlc.narg('assignee_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_assignee')::bool, false)) + OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]) + OR (COALESCE(sqlc.narg('include_no_assignee')::bool, false) AND assignee_id IS NULL) + ) + AND (sqlc.narg('creator_ids')::uuid[] IS NULL OR creator_id = ANY(sqlc.narg('creator_ids')::uuid[])) + AND ( + (sqlc.narg('project_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_project')::bool, false)) + OR project_id = ANY(sqlc.narg('project_ids')::uuid[]) + OR (COALESCE(sqlc.narg('include_no_project')::bool, false) AND project_id IS NULL) + ) + AND (sqlc.narg('label_ids')::uuid[] IS NULL OR EXISTS ( + SELECT 1 FROM issue_to_label il + WHERE il.issue_id = issue.id + AND il.label_id = ANY(sqlc.narg('label_ids')::uuid[]) + )) ORDER BY position ASC, created_at DESC; -- name: CountIssues :one SELECT count(*) FROM issue WHERE workspace_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) - AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) - AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) - AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) - AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) - AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')); + AND (sqlc.narg('priorities')::text[] IS NULL OR priority = ANY(sqlc.narg('priorities')::text[])) + AND (sqlc.narg('assignee_types')::text[] IS NULL OR assignee_type = ANY(sqlc.narg('assignee_types')::text[])) + AND ( + (sqlc.narg('assignee_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_assignee')::bool, false)) + OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]) + OR (COALESCE(sqlc.narg('include_no_assignee')::bool, false) AND assignee_id IS NULL) + ) + AND (sqlc.narg('creator_ids')::uuid[] IS NULL OR creator_id = ANY(sqlc.narg('creator_ids')::uuid[])) + AND ( + (sqlc.narg('project_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_project')::bool, false)) + OR project_id = ANY(sqlc.narg('project_ids')::uuid[]) + OR (COALESCE(sqlc.narg('include_no_project')::bool, false) AND project_id IS NULL) + ) + AND (sqlc.narg('label_ids')::uuid[] IS NULL OR EXISTS ( + SELECT 1 FROM issue_to_label il + WHERE il.issue_id = issue.id + AND il.label_id = ANY(sqlc.narg('label_ids')::uuid[]) + )); -- name: ListChildIssues :many SELECT * FROM issue From abd69890a8fa350ea92ba533dc2ea459dfb7c806 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:29:42 +0800 Subject: [PATCH 28/38] =?UTF-8?q?Revert=20"feat(issues):=20server-side=20f?= =?UTF-8?q?ilters=20incl.=20label,=20fixing=20pagination=20drop=E2=80=A6"?= =?UTF-8?q?=20(#1779)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 246fcd4ce44900238329ee384fc5ff88b093db62. --- packages/core/api/client.ts | 11 +- packages/core/issues/mutations.ts | 135 ++++-------- packages/core/issues/queries.ts | 59 ++---- packages/core/issues/stores/view-store.ts | 11 - packages/core/issues/ws-updaters.ts | 51 +---- packages/core/types/api.ts | 15 +- .../extensions/mention-suggestion.test.tsx | 36 +--- .../editor/extensions/mention-suggestion.tsx | 18 +- .../views/issues/components/board-view.tsx | 43 ++-- .../views/issues/components/issues-header.tsx | 104 +-------- .../issues/components/issues-page.test.tsx | 2 - .../views/issues/components/issues-page.tsx | 52 ++--- .../views/issues/components/list-view.tsx | 27 +-- packages/views/issues/utils/filter.test.ts | 199 ++++++++++++------ packages/views/issues/utils/filter.ts | 101 +++++---- .../my-issues/components/my-issues-page.tsx | 49 +++-- .../projects/components/project-detail.tsx | 46 ++-- server/cmd/multica/cmd_issue.go | 4 - server/internal/handler/handler.go | 28 --- server/internal/handler/issue.go | 139 +++++------- server/pkg/db/generated/issue.sql.go | 155 +++++--------- server/pkg/db/queries/issue.sql | 69 ++---- 22 files changed, 470 insertions(+), 884 deletions(-) diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 8ba3b241f3..8822d4f917 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -363,14 +363,11 @@ export class ApiClient { if (params?.offset) search.set("offset", String(params.offset)); if (params?.workspace_id) search.set("workspace_id", params.workspace_id); if (params?.status) search.set("status", params.status); - if (params?.priorities?.length) search.set("priorities", params.priorities.join(",")); - if (params?.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); + if (params?.priority) search.set("priority", params.priority); + if (params?.assignee_id) search.set("assignee_id", params.assignee_id); if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); - if (params?.include_no_assignee) search.set("include_no_assignee", "true"); - if (params?.creator_ids?.length) search.set("creator_ids", params.creator_ids.join(",")); - if (params?.project_ids?.length) search.set("project_ids", params.project_ids.join(",")); - if (params?.include_no_project) search.set("include_no_project", "true"); - if (params?.label_ids?.length) search.set("label_ids", params.label_ids.join(",")); + if (params?.creator_id) search.set("creator_id", params.creator_id); + if (params?.project_id) search.set("project_id", params.project_id); if (params?.open_only) search.set("open_only", "true"); return this.fetch(`/api/issues?${search}`); } diff --git a/packages/core/issues/mutations.ts b/packages/core/issues/mutations.ts index 0e7544071f..f9bddbd3ac 100644 --- a/packages/core/issues/mutations.ts +++ b/packages/core/issues/mutations.ts @@ -1,10 +1,9 @@ import { useState, useCallback } from "react"; -import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api"; import { issueKeys, ISSUE_PAGE_SIZE, - type IssueListFilter, type MyIssuesFilter, } from "./queries"; import { @@ -25,60 +24,6 @@ import type { } from "../types"; import type { TimelineEntry, IssueSubscriber, Reaction } from "../types"; -// --------------------------------------------------------------------------- -// Cache helpers — apply an updater to every filter-keyed list cache. -// --------------------------------------------------------------------------- - -/** - * Calls `updater` against every workspace issue-list cache (all filter - * combinations under `["issues", wsId, "list", *]`). Filter-keyed caches mean - * the same workspace can have many list entries; mutations need to update or - * snapshot all of them so the UI stays consistent regardless of which filter - * the user has active. - */ -function updateAllListCaches( - qc: QueryClient, - wsId: string, - updater: (old: ListIssuesCache | undefined) => ListIssuesCache | undefined, -) { - return qc.setQueriesData( - { queryKey: issueKeys.listPrefix(wsId) }, - updater, - ); -} - -/** Snapshot every workspace list cache so mutations can roll back on error. */ -function snapshotAllListCaches(qc: QueryClient, wsId: string) { - return qc - .getQueriesData({ queryKey: issueKeys.listPrefix(wsId) }) - .map(([key, data]) => ({ key, data })); -} - -function restoreListCacheSnapshots( - qc: QueryClient, - snapshots: { key: readonly unknown[]; data: ListIssuesCache | undefined }[], -) { - for (const { key, data } of snapshots) { - if (data !== undefined) qc.setQueryData(key, data); - } -} - -function findIssueAcrossListCaches( - qc: QueryClient, - wsId: string, - issueId: string, -) { - const entries = qc.getQueriesData({ - queryKey: issueKeys.listPrefix(wsId), - }); - for (const [, cache] of entries) { - if (!cache) continue; - const loc = findIssueLocation(cache, issueId); - if (loc) return loc.issue; - } - return undefined; -} - // --------------------------------------------------------------------------- // Shared mutation variable types — used by both mutation hooks and // useMutationState consumers to keep the type assertion in sync. @@ -106,17 +51,15 @@ export type ToggleIssueReactionVars = { */ export function useLoadMoreByStatus( status: IssueStatus, - options: { filter?: IssueListFilter; myIssues?: { scope: string; filter: MyIssuesFilter } } = {}, + myIssues?: { scope: string; filter: MyIssuesFilter }, ) { - const { filter, myIssues } = options; const qc = useQueryClient(); const wsId = useWorkspaceId(); const [isLoading, setIsLoading] = useState(false); const queryKey = myIssues ? issueKeys.myList(wsId, myIssues.scope, myIssues.filter) - : issueKeys.list(wsId, filter); - const requestFilter = myIssues?.filter ?? filter ?? {}; + : issueKeys.list(wsId); const cache = qc.getQueryData(queryKey); const bucket = cache?.byStatus[status]; const loaded = bucket?.issues.length ?? 0; @@ -131,7 +74,7 @@ export function useLoadMoreByStatus( status, limit: ISSUE_PAGE_SIZE, offset: loaded, - ...requestFilter, + ...myIssues?.filter, }); qc.setQueryData(queryKey, (old) => { if (!old) return old; @@ -146,7 +89,7 @@ export function useLoadMoreByStatus( } finally { setIsLoading(false); } - }, [qc, queryKey, status, loaded, hasMore, isLoading, requestFilter]); + }, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]); return { loadMore, hasMore, isLoading, total }; } @@ -161,11 +104,7 @@ export function useCreateIssue() { return useMutation({ mutationFn: (data: CreateIssueRequest) => api.createIssue(data), onSuccess: (newIssue) => { - // Insert the new issue into every active list cache. Filter-keyed - // caches are invalidated on settled below, so a momentary inclusion - // in a cache that "shouldn't" contain the issue is corrected within - // the same tick. - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? addIssueToBuckets(old, newIssue) : old, ); // Surface the just-created issue in cmd+k's Recent list without @@ -178,7 +117,7 @@ export function useCreateIssue() { } }, onSettled: () => { - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); }, }); } @@ -194,8 +133,8 @@ export function useUpdateIssue() { // cache update happens in the same tick as mutate(). Awaiting would // yield to the event loop, letting @dnd-kit reset its visual state // before the optimistic update lands. - qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); - const listSnapshots = snapshotAllListCaches(qc, wsId); + qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); // Resolve parent_issue_id from the freshest source so we can keep the @@ -203,13 +142,13 @@ export function useUpdateIssue() { // sub-issues list). const parentId = prevDetail?.parent_issue_id ?? - findIssueAcrossListCaches(qc, wsId, id)?.parent_issue_id ?? + (prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ?? null; const prevChildren = parentId ? qc.getQueryData(issueKeys.children(wsId, parentId)) : undefined; - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? patchIssueInBuckets(old, id, data) : old, ); qc.setQueryData(issueKeys.detail(wsId, id), (old) => @@ -222,10 +161,10 @@ export function useUpdateIssue() { old?.map((c) => (c.id === id ? { ...c, ...data } : c)), ); } - return { listSnapshots, prevDetail, prevChildren, parentId, id }; + return { prevList, prevDetail, prevChildren, parentId, id }; }, onError: (_err, _vars, ctx) => { - if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); if (ctx?.prevDetail) qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail); if (ctx?.parentId && ctx.prevChildren !== undefined) { @@ -237,7 +176,7 @@ export function useUpdateIssue() { }, onSettled: (_data, _err, vars, ctx) => { qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) }); - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); // Invalidate old parent's children cache if (ctx?.parentId) { qc.invalidateQueries({ @@ -263,20 +202,20 @@ export function useDeleteIssue() { return useMutation({ mutationFn: (id: string) => api.deleteIssue(id), onMutate: async (id) => { - await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); - const listSnapshots = snapshotAllListCaches(qc, wsId); - const deleted = findIssueAcrossListCaches(qc, wsId, id); - updateAllListCaches(qc, wsId, (old) => + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined; + qc.setQueryData(issueKeys.list(wsId), (old) => old ? removeIssueFromBuckets(old, id) : old, ); qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) }); - return { listSnapshots, parentIssueId: deleted?.parent_issue_id }; + return { prevList, parentIssueId: deleted?.parent_issue_id }; }, onError: (_err, _id, ctx) => { - if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); }, onSettled: (_data, _err, _id, ctx) => { - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); if (ctx?.parentIssueId) { qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) }); qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) }); @@ -297,21 +236,21 @@ export function useBatchUpdateIssues() { updates: UpdateIssueRequest; }) => api.batchUpdateIssues(ids, updates), onMutate: async ({ ids, updates }) => { - await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); - const listSnapshots = snapshotAllListCaches(qc, wsId); - updateAllListCaches(qc, wsId, (old) => { + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); + qc.setQueryData(issueKeys.list(wsId), (old) => { if (!old) return old; let next = old; for (const id of ids) next = patchIssueInBuckets(next, id, updates); return next; }); - return { listSnapshots }; + return { prevList }; }, onError: (_err, _vars, ctx) => { - if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); }, onSettled: () => { - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); }, }); } @@ -322,26 +261,28 @@ export function useBatchDeleteIssues() { return useMutation({ mutationFn: (ids: string[]) => api.batchDeleteIssues(ids), onMutate: async (ids) => { - await qc.cancelQueries({ queryKey: issueKeys.listPrefix(wsId) }); - const listSnapshots = snapshotAllListCaches(qc, wsId); + await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); + const prevList = qc.getQueryData(issueKeys.list(wsId)); const parentIssueIds = new Set(); - for (const id of ids) { - const found = findIssueAcrossListCaches(qc, wsId, id); - if (found?.parent_issue_id) parentIssueIds.add(found.parent_issue_id); + if (prevList) { + for (const id of ids) { + const loc = findIssueLocation(prevList, id); + if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id); + } } - updateAllListCaches(qc, wsId, (old) => { + qc.setQueryData(issueKeys.list(wsId), (old) => { if (!old) return old; let next = old; for (const id of ids) next = removeIssueFromBuckets(next, id); return next; }); - return { listSnapshots, parentIssueIds }; + return { prevList, parentIssueIds }; }, onError: (_err, _ids, ctx) => { - if (ctx?.listSnapshots) restoreListCacheSnapshots(qc, ctx.listSnapshots); + if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList); }, onSettled: (_data, _err, _ids, ctx) => { - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) { for (const parentId of ctx.parentIssueIds) { qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) }); diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index f84e331fd7..dd1a35679b 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -5,14 +5,12 @@ import { BOARD_STATUSES } from "./config"; export const issueKeys = { all: (wsId: string) => ["issues", wsId] as const, - /** Filter-keyed list. Empty filter is the canonical workspace-wide list. */ - list: (wsId: string, filter: IssueListFilter = EMPTY_FILTER) => - [...issueKeys.all(wsId), "list", normalizeFilter(filter)] as const, + list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const, /** All "my issues" queries — use for bulk invalidation. */ myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const, /** Per-scope "my issues" list with filter identity baked into the key. */ - myList: (wsId: string, scope: string, filter: IssueListFilter) => - [...issueKeys.myAll(wsId), scope, normalizeFilter(filter)] as const, + myList: (wsId: string, scope: string, filter: MyIssuesFilter) => + [...issueKeys.myAll(wsId), scope, filter] as const, detail: (wsId: string, id: string) => [...issueKeys.all(wsId), "detail", id] as const, children: (wsId: string, id: string) => @@ -24,44 +22,13 @@ export const issueKeys = { subscribers: (issueId: string) => ["issues", "subscribers", issueId] as const, usage: (issueId: string) => ["issues", "usage", issueId] as const, - /** Prefix used by mutations to broadcast cache updates across all filters. */ - listPrefix: (wsId: string) => [...issueKeys.all(wsId), "list"] as const, }; -/** - * Server-side filter passed to `GET /api/issues`. Status is excluded because - * the cache buckets per-status — each bucket is fetched with its own status - * query param (see {@link fetchFirstPages}). - */ -export type IssueListFilter = Omit< +export type MyIssuesFilter = Pick< ListIssuesParams, - "limit" | "offset" | "workspace_id" | "status" | "open_only" + "assignee_id" | "assignee_ids" | "creator_id" | "project_id" >; -/** Backwards-compat alias — old name was specific to the My Issues page. */ -export type MyIssuesFilter = IssueListFilter; - -const EMPTY_FILTER: IssueListFilter = {}; - -/** - * Normalizes a filter object so semantically-identical filters produce - * identical query keys. Sorts arrays (so `[a, b]` and `[b, a]` cache to the - * same key) and drops empty/falsy entries (so `{}` and `{ priorities: [] }` - * are equivalent). - */ -function normalizeFilter(filter: IssueListFilter): IssueListFilter { - const out: IssueListFilter = {}; - if (filter.priorities?.length) out.priorities = [...filter.priorities].sort(); - if (filter.assignee_types?.length) out.assignee_types = [...filter.assignee_types].sort(); - if (filter.assignee_ids?.length) out.assignee_ids = [...filter.assignee_ids].sort(); - if (filter.include_no_assignee) out.include_no_assignee = true; - if (filter.creator_ids?.length) out.creator_ids = [...filter.creator_ids].sort(); - if (filter.project_ids?.length) out.project_ids = [...filter.project_ids].sort(); - if (filter.include_no_project) out.include_no_project = true; - if (filter.label_ids?.length) out.label_ids = [...filter.label_ids].sort(); - return out; -} - /** Page size per status column. */ export const ISSUE_PAGE_SIZE = 50; @@ -78,7 +45,7 @@ export function flattenIssueBuckets(data: ListIssuesCache) { return out; } -async function fetchFirstPages(filter: IssueListFilter = {}): Promise { +async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise { const responses = await Promise.all( PAGINATED_STATUSES.map((status) => api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }), @@ -98,15 +65,13 @@ async function fetchFirstPages(filter: IssueListFilter = {}): Promise(...)` and preserve the byStatus shape. * - * Fetches the first page of each paginated status in parallel. Filter goes - * into both the cache key and the request, so filter changes trigger a - * fresh server-side fetch and don't share cache with other filters. Use - * {@link useLoadMoreByStatus} to paginate a specific status. + * Fetches the first page of each paginated status in parallel. Use + * {@link useLoadMoreByStatus} to paginate a specific status into the cache. */ -export function issueListOptions(wsId: string, filter: IssueListFilter = EMPTY_FILTER) { +export function issueListOptions(wsId: string) { return queryOptions({ - queryKey: issueKeys.list(wsId, filter), - queryFn: () => fetchFirstPages(filter), + queryKey: issueKeys.list(wsId), + queryFn: () => fetchFirstPages(), select: flattenIssueBuckets, }); } @@ -118,7 +83,7 @@ export function issueListOptions(wsId: string, filter: IssueListFilter = EMPTY_F export function myIssueListOptions( wsId: string, scope: string, - filter: IssueListFilter, + filter: MyIssuesFilter, ) { return queryOptions({ queryKey: issueKeys.myList(wsId, scope, filter), diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index e61b8f15e3..c704ba62b4 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -55,7 +55,6 @@ export interface IssueViewState { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; - labelFilters: string[]; sortBy: SortField; sortDirection: SortDirection; cardProperties: CardProperties; @@ -68,7 +67,6 @@ export interface IssueViewState { toggleCreatorFilter: (value: ActorFilterValue) => void; toggleProjectFilter: (projectId: string) => void; toggleNoProject: () => void; - toggleLabelFilter: (labelId: string) => void; hideStatus: (status: IssueStatus) => void; showStatus: (status: IssueStatus) => void; clearFilters: () => void; @@ -87,7 +85,6 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, - labelFilters: [], sortBy: "position", sortDirection: "asc", cardProperties: { @@ -150,12 +147,6 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue })), toggleNoProject: () => set((state) => ({ includeNoProject: !state.includeNoProject })), - toggleLabelFilter: (labelId) => - set((state) => ({ - labelFilters: state.labelFilters.includes(labelId) - ? state.labelFilters.filter((id) => id !== labelId) - : [...state.labelFilters, labelId], - })), hideStatus: (status) => set((state) => { // If no filter active, activate filter with all EXCEPT this one @@ -181,7 +172,6 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, - labelFilters: [], }), setSortBy: (field) => set({ sortBy: field }), setSortDirection: (dir) => set({ sortDirection: dir }), @@ -212,7 +202,6 @@ export const viewStorePersistOptions = (name: string) => ({ creatorFilters: state.creatorFilters, projectFilters: state.projectFilters, includeNoProject: state.includeNoProject, - labelFilters: state.labelFilters, sortBy: state.sortBy, sortDirection: state.sortDirection, cardProperties: state.cardProperties, diff --git a/packages/core/issues/ws-updaters.ts b/packages/core/issues/ws-updaters.ts index 1e137135c6..299019fb11 100644 --- a/packages/core/issues/ws-updaters.ts +++ b/packages/core/issues/ws-updaters.ts @@ -9,45 +9,14 @@ import { import type { Issue, Label } from "../types"; import type { ListIssuesCache } from "../types"; -/** Apply an updater to every workspace list cache (all filter combinations). */ -function updateAllListCaches( - qc: QueryClient, - wsId: string, - updater: (old: ListIssuesCache | undefined) => ListIssuesCache | undefined, -) { - qc.setQueriesData( - { queryKey: issueKeys.listPrefix(wsId) }, - updater, - ); -} - -function findIssueAcrossListCaches( - qc: QueryClient, - wsId: string, - issueId: string, -) { - const entries = qc.getQueriesData({ - queryKey: issueKeys.listPrefix(wsId), - }); - for (const [, cache] of entries) { - if (!cache) continue; - const loc = findIssueLocation(cache, issueId); - if (loc) return loc.issue; - } - return undefined; -} - export function onIssueCreated( qc: QueryClient, wsId: string, issue: Issue, ) { - // Insert into every active list cache. Filter mismatches are corrected - // by the trailing `invalidateQueries` on the listPrefix. - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? addIssueToBuckets(old, issue) : old, ); - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); if (issue.parent_issue_id) { qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) }); @@ -63,17 +32,18 @@ export function onIssueUpdated( // Look up the OLD parent before mutating list state, so we can keep // the parent's children cache in sync (powers the sub-issues list // shown on the parent issue page). + const listData = qc.getQueryData(issueKeys.list(wsId)); const detailData = qc.getQueryData(issueKeys.detail(wsId, issue.id)); const oldParentId = detailData?.parent_issue_id ?? - findIssueAcrossListCaches(qc, wsId, issue.id)?.parent_issue_id ?? + (listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ?? null; // The NEW parent comes from the WS payload when parent_issue_id changed const newParentId = issue.parent_issue_id ?? null; const parentChanged = issue.parent_issue_id !== undefined && newParentId !== oldParentId; - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? patchIssueInBuckets(old, issue.id, issue) : old, ); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); @@ -106,9 +76,6 @@ export function onIssueUpdated( * Patch an issue's `labels` field in-place across the list cache, my-issues * caches, and the detail cache. Triggered by the `issue_labels:changed` WS * event after attach/detach so list/board chips update without a refetch. - * - * Also invalidates the listPrefix because a label change can move issues in - * or out of an active label filter. */ export function onIssueLabelsChanged( qc: QueryClient, @@ -116,13 +83,12 @@ export function onIssueLabelsChanged( issueId: string, labels: Label[], ) { - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? patchIssueInBuckets(old, issueId, { labels }) : old, ); qc.setQueryData(issueKeys.detail(wsId, issueId), (old) => old ? { ...old, labels } : old, ); - qc.invalidateQueries({ queryKey: issueKeys.listPrefix(wsId) }); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); } @@ -131,10 +97,11 @@ export function onIssueDeleted( wsId: string, issueId: string, ) { - // Look up the issue before removing it to check for parent_issue_id. - const deleted = findIssueAcrossListCaches(qc, wsId, issueId); + // Look up the issue before removing it to check for parent_issue_id + const listData = qc.getQueryData(issueKeys.list(wsId)); + const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined; - updateAllListCaches(qc, wsId, (old) => + qc.setQueryData(issueKeys.list(wsId), (old) => old ? removeIssueFromBuckets(old, issueId) : old, ); qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 80aa6e923c..aa6e0bca9a 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -34,18 +34,11 @@ export interface ListIssuesParams { offset?: number; workspace_id?: string; status?: IssueStatus; - priorities?: IssuePriority[]; - /** Match issues whose assignee is one of these polymorphic types ("member"/"agent"). */ - assignee_types?: IssueAssigneeType[]; + priority?: IssuePriority; + assignee_id?: string; assignee_ids?: string[]; - /** When true, also include issues with no assignee (OR'd with assignee_ids). */ - include_no_assignee?: boolean; - creator_ids?: string[]; - project_ids?: string[]; - /** When true, also include issues with no project (OR'd with project_ids). */ - include_no_project?: boolean; - /** Match issues that have at least one of these labels. */ - label_ids?: string[]; + creator_id?: string; + project_id?: string; open_only?: boolean; } diff --git a/packages/views/editor/extensions/mention-suggestion.test.tsx b/packages/views/editor/extensions/mention-suggestion.test.tsx index 0a39f6415b..ef60322fe0 100644 --- a/packages/views/editor/extensions/mention-suggestion.test.tsx +++ b/packages/views/editor/extensions/mention-suggestion.test.tsx @@ -26,40 +26,20 @@ function fakeQc(data: { agents?: Array<{ id: string; name: string; archived_at: string | null }>; issues?: Array<{ id: string; identifier: string; title: string; status: string }>; }): QueryClient { - const map = new Map(); - map.set(JSON.stringify(workspaceKeys.members("ws-1")), { - key: workspaceKeys.members("ws-1"), - data: data.members ?? [], - }); - map.set(JSON.stringify(workspaceKeys.agents("ws-1")), { - key: workspaceKeys.agents("ws-1"), - data: data.agents ?? [], - }); + const map = new Map(); + map.set(JSON.stringify(workspaceKeys.members("ws-1")), data.members ?? []); + map.set(JSON.stringify(workspaceKeys.agents("ws-1")), data.agents ?? []); const byStatus: ListIssuesCache["byStatus"] = {}; for (const status of PAGINATED_STATUSES) { const bucket = (data.issues ?? []).filter((i) => i.status === status); byStatus[status as IssueStatus] = { issues: bucket as never, total: bucket.length }; } - const listKey = issueKeys.list("ws-1"); - map.set(JSON.stringify(listKey), { - key: listKey, - data: { byStatus } satisfies ListIssuesCache, - }); + map.set( + JSON.stringify(issueKeys.list("ws-1")), + { byStatus } satisfies ListIssuesCache, + ); return { - getQueryData: (key: readonly unknown[]) => - map.get(JSON.stringify(key))?.data, - // Prefix-match implementation: any cached key whose prefix equals the - // filter's queryKey is returned. Mirrors TanStack's behavior closely - // enough for our cache-read paths. - getQueriesData: ({ queryKey }: { queryKey: readonly unknown[] }) => { - const prefix = JSON.stringify(queryKey); - const trimmed = prefix.slice(0, -1); // strip trailing ']' - const out: Array = []; - for (const [k, entry] of map.entries()) { - if (k.startsWith(trimmed)) out.push([entry.key, entry.data] as const); - } - return out; - }, + getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)), } as unknown as QueryClient; } diff --git a/packages/views/editor/extensions/mention-suggestion.tsx b/packages/views/editor/extensions/mention-suggestion.tsx index 659e8ca794..000cad1f4d 100644 --- a/packages/views/editor/extensions/mention-suggestion.tsx +++ b/packages/views/editor/extensions/mention-suggestion.tsx @@ -254,22 +254,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit< const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? []; const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? []; - // List caches are filter-keyed, so a single workspace can have multiple - // entries. Pull from whichever caches exist and dedupe — the goal is just - // an instant first paint; the server search below fills in misses. - const cachedListEntries = qc.getQueriesData({ - queryKey: issueKeys.listPrefix(wsId), - }); - const seen = new Set(); - const cachedIssues: Issue[] = []; - for (const [, cache] of cachedListEntries) { - if (!cache) continue; - for (const issue of flattenIssueBuckets(cache)) { - if (seen.has(issue.id)) continue; - seen.add(issue.id); - cachedIssues.push(issue); - } - } + const cachedResponse = qc.getQueryData(issueKeys.list(wsId)); + const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : []; const q = query.toLowerCase(); diff --git a/packages/views/issues/components/board-view.tsx b/packages/views/issues/components/board-view.tsx index 4093e93717..4cf96b1e67 100644 --- a/packages/views/issues/components/board-view.tsx +++ b/packages/views/issues/components/board-view.tsx @@ -19,17 +19,7 @@ import { Eye, MoreHorizontal } from "lucide-react"; import type { Issue, IssueStatus } from "@multica/core/types"; import { Button } from "@multica/ui/components/ui/button"; import { useLoadMoreByStatus } from "@multica/core/issues/mutations"; -import type { IssueListFilter, MyIssuesFilter } from "@multica/core/issues/queries"; - -/** - * Threaded between BoardView and its inner components — selects which list - * cache `useLoadMoreByStatus` paginates. The workspace list path keys on - * `filter`; the My Issues path keys on `(scope, filter)` because each scope - * has its own cache entry. - */ -type LoadMoreOptions = - | { filter?: IssueListFilter; myIssues?: never } - | { filter?: never; myIssues: { scope: string; filter: MyIssuesFilter } }; +import type { MyIssuesFilter } from "@multica/core/issues/queries"; import { DropdownMenu, DropdownMenuTrigger, @@ -113,7 +103,6 @@ export function BoardView({ hiddenStatuses, onMoveIssue, childProgressMap = EMPTY_PROGRESS_MAP, - listFilter, myIssuesScope, myIssuesFilter, }: { @@ -126,17 +115,15 @@ export function BoardView({ newPosition?: number ) => void; childProgressMap?: Map; - /** Filter that keys the workspace list cache. Threaded into useLoadMoreByStatus so pagination targets the same filtered bucket the page is rendering. */ - listFilter?: IssueListFilter; /** When set, per-status load-more targets the scoped cache instead of the workspace one. */ myIssuesScope?: string; myIssuesFilter?: MyIssuesFilter; }) { const sortBy = useViewStore((s) => s.sortBy); const sortDirection = useViewStore((s) => s.sortDirection); - const loadMoreOptions = myIssuesScope - ? { myIssues: { scope: myIssuesScope, filter: myIssuesFilter ?? {} } } - : { filter: listFilter }; + const myIssuesOpts = myIssuesScope + ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } + : undefined; // --- Drag state --- const [activeIssue, setActiveIssue] = useState(null); @@ -300,14 +287,14 @@ export function BoardView({ issueIds={columns[status] ?? []} issueMap={issueMapRef.current} childProgressMap={childProgressMap} - loadMoreOptions={loadMoreOptions} + myIssuesOpts={myIssuesOpts} /> ))} {hiddenStatuses.length > 0 && ( )}
@@ -328,17 +315,17 @@ function PaginatedBoardColumn({ issueIds, issueMap, childProgressMap, - loadMoreOptions, + myIssuesOpts, }: { status: IssueStatus; issueIds: string[]; issueMap: Map; childProgressMap?: Map; - loadMoreOptions: LoadMoreOptions; + myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; }) { const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus( status, - loadMoreOptions, + myIssuesOpts, ); return ( @@ -375,7 +362,7 @@ function HiddenColumnsPanel({ ))}
@@ -385,14 +372,14 @@ function HiddenColumnsPanel({ function HiddenColumnRow({ status, - loadMoreOptions, + myIssuesOpts, }: { status: IssueStatus; - loadMoreOptions: LoadMoreOptions; + myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; }) { const cfg = STATUS_CONFIG[status]; const viewStoreApi = useViewStoreApi(); - const { total } = useLoadMoreByStatus(status, loadMoreOptions); + const { total } = useLoadMoreByStatus(status, myIssuesOpts); return (
diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index c9e955d845..878b6e9482 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -14,7 +14,6 @@ import { List, SignalHigh, SlidersHorizontal, - Tag, User, UserMinus, UserPen, @@ -50,10 +49,8 @@ import { useQuery } from "@tanstack/react-query"; import { useWorkspaceId } from "@multica/core/hooks"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; -import { labelListOptions } from "@multica/core/labels/queries"; import { ProjectIcon } from "../../projects/components/project-icon"; import { ActorAvatar } from "../../common/actor-avatar"; -import { LabelChip } from "../../labels/label-chip"; import { SORT_OPTIONS, CARD_PROPERTY_OPTIONS, @@ -97,7 +94,6 @@ function getActiveFilterCount(state: { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; - labelFilters: string[]; }) { let count = 0; if (state.statusFilters.length > 0) count++; @@ -105,7 +101,6 @@ function getActiveFilterCount(state: { if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++; if (state.creatorFilters.length > 0) count++; if (state.projectFilters.length > 0 || state.includeNoProject) count++; - if (state.labelFilters.length > 0) count++; return count; } @@ -116,7 +111,6 @@ function useIssueCounts(allIssues: Issue[]) { const assignee = new Map(); const creator = new Map(); const project = new Map(); - const label = new Map(); let noAssignee = 0; let noProject = 0; @@ -139,15 +133,9 @@ function useIssueCounts(allIssues: Issue[]) { } else { project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1); } - - if (issue.labels) { - for (const l of issue.labels) { - label.set(l.id, (label.get(l.id) ?? 0) + 1); - } - } } - return { status, priority, assignee, creator, noAssignee, project, noProject, label }; + return { status, priority, assignee, creator, noAssignee, project, noProject }; }, [allIssues]); } @@ -387,75 +375,11 @@ function ProjectSubContent({ ); } -// --------------------------------------------------------------------------- -// Label sub-menu content -// --------------------------------------------------------------------------- - -function LabelSubContent({ - counts, - selected, - onToggle, -}: { - counts: Map; - selected: string[]; - onToggle: (labelId: string) => void; -}) { - const [search, setSearch] = useState(""); - const wsId = useWorkspaceId(); - const { data: labels = [] } = useQuery(labelListOptions(wsId)); - const query = search.trim().toLowerCase(); - const filtered = labels.filter((l) => l.name.toLowerCase().includes(query)); - - return ( - <> -
- setSearch(e.target.value)} - placeholder="Filter..." - className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" - autoFocus - /> -
- -
- {filtered.map((l) => { - const checked = selected.includes(l.id); - const count = counts.get(l.id) ?? 0; - return ( - onToggle(l.id)} - className={FILTER_ITEM_CLASS} - > - - - {count > 0 && ( - - {count} - - )} - - ); - })} - - {filtered.length === 0 && ( -
- {search ? "No results" : "No labels yet"} -
- )} -
- - ); -} - // --------------------------------------------------------------------------- // IssuesHeader // --------------------------------------------------------------------------- -export function IssuesHeader({ issues }: { issues: Issue[] }) { +export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { const scope = useIssuesScopeStore((s) => s.scope); const setScope = useIssuesScopeStore((s) => s.setScope); @@ -467,13 +391,12 @@ export function IssuesHeader({ issues }: { issues: Issue[] }) { const creatorFilters = useViewStore((s) => s.creatorFilters); const projectFilters = useViewStore((s) => s.projectFilters); const includeNoProject = useViewStore((s) => s.includeNoProject); - const labelFilters = useViewStore((s) => s.labelFilters); const sortBy = useViewStore((s) => s.sortBy); const sortDirection = useViewStore((s) => s.sortDirection); const cardProperties = useViewStore((s) => s.cardProperties); const act = useViewStoreApi().getState(); - const counts = useIssueCounts(issues); + const counts = useIssueCounts(scopedIssues); const hasActiveFilters = getActiveFilterCount({ @@ -484,7 +407,6 @@ export function IssuesHeader({ issues }: { issues: Issue[] }) { creatorFilters, projectFilters, includeNoProject, - labelFilters, }) > 0; const sortLabel = @@ -678,26 +600,6 @@ export function IssuesHeader({ issues }: { issues: Issue[] }) { - {/* Label */} - - - - Label - {labelFilters.length > 0 && ( - - {labelFilters.length} - - )} - - - - - - {/* Reset */} {hasActiveFilters && ( <> diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index 54db9e80b8..d543443c04 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -106,7 +106,6 @@ const mockViewState = { creatorFilters: [] as { type: string; id: string }[], projectFilters: [] as string[], includeNoProject: false, - labelFilters: [] as string[], sortBy: "position" as const, sortDirection: "asc" as const, cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true }, @@ -119,7 +118,6 @@ const mockViewState = { toggleCreatorFilter: vi.fn(), toggleProjectFilter: vi.fn(), toggleNoProject: vi.fn(), - toggleLabelFilter: vi.fn(), hideStatus: vi.fn(), showStatus: vi.fn(), clearFilters: vi.fn(), diff --git a/packages/views/issues/components/issues-page.tsx b/packages/views/issues/components/issues-page.tsx index 5f283800d5..dc2901b270 100644 --- a/packages/views/issues/components/issues-page.tsx +++ b/packages/views/issues/components/issues-page.tsx @@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query"; import { useIssueViewStore, useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store"; import { useIssuesScopeStore } from "@multica/core/issues/stores/issues-scope-store"; import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context"; -import { buildIssueListFilter } from "../utils/filter"; +import { filterIssues } from "../utils/filter"; import { BOARD_STATUSES } from "@multica/core/issues/config"; import { useCurrentWorkspace } from "@multica/core/paths"; import { WorkspaceAvatar } from "../../workspace/workspace-avatar"; @@ -25,6 +25,7 @@ import { BatchActionToolbar } from "./batch-action-toolbar"; export function IssuesPage() { const wsId = useWorkspaceId(); + const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); const workspace = useCurrentWorkspace(); const scope = useIssuesScopeStore((s) => s.scope); @@ -36,7 +37,6 @@ export function IssuesPage() { const creatorFilters = useIssueViewStore((s) => s.creatorFilters); const projectFilters = useIssueViewStore((s) => s.projectFilters); const includeNoProject = useIssueViewStore((s) => s.includeNoProject); - const labelFilters = useIssueViewStore((s) => s.labelFilters); // Clear filter state when switching between workspaces (URL-driven). useClearFiltersOnWorkspaceChange(useIssueViewStore, wsId); @@ -45,30 +45,18 @@ export function IssuesPage() { useIssueSelectionStore.getState().clear(); }, [viewMode, scope]); - // Server-side filter: every active filter goes into the GET /api/issues - // query string so each status bucket fetches the *correct first 50*. With - // client-side filtering issues outside the first page silently fell out of - // view (#1491). Status is intentionally not part of the wire filter — each - // bucket fetches its own status, and we hide buckets via `visibleStatuses`. - // Scope (Members/Agents) is mapped to assignee_types so it routes through - // the same SQL filter — applying scope client-side reproduced the same - // pagination-blind bug for the scope tabs. - const listFilter = useMemo(() => { - const base = buildIssueListFilter({ - priorityFilters, - assigneeFilters, - includeNoAssignee, - creatorFilters, - projectFilters, - includeNoProject, - labelFilters, - }); - if (scope === "members") return { ...base, assignee_types: ["member" as const] }; - if (scope === "agents") return { ...base, assignee_types: ["agent" as const] }; - return base; - }, [scope, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters]); - const { data: issues = [], isLoading: loading } = useQuery( - issueListOptions(wsId, listFilter), + // Scope pre-filter: narrow by assignee type + const scopedIssues = useMemo(() => { + if (scope === "members") + return allIssues.filter((i) => i.assignee_type === "member"); + if (scope === "agents") + return allIssues.filter((i) => i.assignee_type === "agent"); + return allIssues; + }, [allIssues, scope]); + + const issues = useMemo( + () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject }), + [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject], ); // Fetch sub-issue progress from the backend so counts are accurate @@ -162,10 +150,10 @@ export function IssuesPage() { {/* Header 2: Scope tabs + filters */} - + {/* Content: scrollable */} - {issues.length === 0 ? ( + {scopedIssues.length === 0 ? (

No issues yet

@@ -180,15 +168,9 @@ export function IssuesPage() { hiddenStatuses={hiddenStatuses} onMoveIssue={handleMoveIssue} childProgressMap={childProgressMap} - listFilter={listFilter} /> ) : ( - + )}
)} diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx index 81b47b194c..0cef6e4ce3 100644 --- a/packages/views/issues/components/list-view.tsx +++ b/packages/views/issues/components/list-view.tsx @@ -7,7 +7,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ import { Button } from "@multica/ui/components/ui/button"; import type { Issue, IssueStatus } from "@multica/core/types"; import { useLoadMoreByStatus } from "@multica/core/issues/mutations"; -import type { IssueListFilter, MyIssuesFilter } from "@multica/core/issues/queries"; +import type { MyIssuesFilter } from "@multica/core/issues/queries"; import { STATUS_CONFIG } from "@multica/core/issues/config"; import { useModalStore } from "@multica/core/modals"; import { useViewStore } from "@multica/core/issues/stores/view-store-context"; @@ -19,27 +19,16 @@ import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; const EMPTY_PROGRESS_MAP = new Map(); -/** - * Threaded into useLoadMoreByStatus — keeps pagination requests on the same - * filter (or My Issues scope) as the page is currently rendering. - */ -type LoadMoreOptions = - | { filter?: IssueListFilter; myIssues?: never } - | { filter?: never; myIssues: { scope: string; filter: MyIssuesFilter } }; - export function ListView({ issues, visibleStatuses, childProgressMap = EMPTY_PROGRESS_MAP, - listFilter, myIssuesScope, myIssuesFilter, }: { issues: Issue[]; visibleStatuses: IssueStatus[]; childProgressMap?: Map; - /** Workspace list filter — pagination targets the matching filter cache. */ - listFilter?: IssueListFilter; /** When set, per-status load-more targets the scoped cache instead of the workspace one. */ myIssuesScope?: string; myIssuesFilter?: MyIssuesFilter; @@ -70,9 +59,9 @@ export function ListView({ [visibleStatuses, listCollapsedStatuses] ); - const loadMoreOptions: LoadMoreOptions = myIssuesScope - ? { myIssues: { scope: myIssuesScope, filter: myIssuesFilter ?? {} } } - : { filter: listFilter }; + const myIssuesOpts = myIssuesScope + ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } + : undefined; return (
@@ -96,7 +85,7 @@ export function ListView({ status={status} issues={issuesByStatus.get(status) ?? []} childProgressMap={childProgressMap} - loadMoreOptions={loadMoreOptions} + myIssuesOpts={myIssuesOpts} /> ))} @@ -108,12 +97,12 @@ function StatusAccordionItem({ status, issues, childProgressMap, - loadMoreOptions, + myIssuesOpts, }: { status: IssueStatus; issues: Issue[]; childProgressMap: Map; - loadMoreOptions: LoadMoreOptions; + myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; }) { const cfg = STATUS_CONFIG[status]; const selectedIds = useIssueSelectionStore((s) => s.selectedIds); @@ -121,7 +110,7 @@ function StatusAccordionItem({ const deselect = useIssueSelectionStore((s) => s.deselect); const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus( status, - loadMoreOptions, + myIssuesOpts, ); const issueIds = issues.map((i) => i.id); diff --git a/packages/views/issues/utils/filter.test.ts b/packages/views/issues/utils/filter.test.ts index e93be40695..adbb4faae7 100644 --- a/packages/views/issues/utils/filter.test.ts +++ b/packages/views/issues/utils/filter.test.ts @@ -1,93 +1,164 @@ import { describe, it, expect } from "vitest"; -import { buildIssueListFilter, type IssueViewFilters } from "./filter"; +import type { Issue } from "@multica/core/types"; +import { filterIssues, type IssueFilters } from "./filter"; -const NO_FILTER: IssueViewFilters = { +const NO_FILTER: IssueFilters = { + statusFilters: [], priorityFilters: [], assigneeFilters: [], includeNoAssignee: false, creatorFilters: [], projectFilters: [], includeNoProject: false, - labelFilters: [], }; -describe("buildIssueListFilter", () => { - it("returns an empty object when nothing is selected", () => { - expect(buildIssueListFilter(NO_FILTER)).toEqual({}); +function makeIssue(overrides: Partial = {}): Issue { + return { + id: "i-1", + workspace_id: "ws-1", + number: 1, + identifier: "MUL-1", + title: "Test", + description: null, + status: "todo", + priority: "medium", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "u-1", + parent_issue_id: null, + project_id: null, + position: 0, + due_date: null, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + ...overrides, + }; +} + +const issues: Issue[] = [ + makeIssue({ id: "1", status: "todo", priority: "high", assignee_type: "member", assignee_id: "u-1", creator_type: "member", creator_id: "u-1", project_id: "p-1" }), + makeIssue({ id: "2", status: "in_progress", priority: "medium", assignee_type: "agent", assignee_id: "a-1", creator_type: "agent", creator_id: "a-1", project_id: "p-2" }), + makeIssue({ id: "3", status: "done", priority: "low", assignee_type: null, assignee_id: null, creator_type: "member", creator_id: "u-2", project_id: null }), + makeIssue({ id: "4", status: "todo", priority: "urgent", assignee_type: "member", assignee_id: "u-2", creator_type: "member", creator_id: "u-1", project_id: "p-1" }), +]; + +describe("filterIssues", () => { + it("returns all issues when no filters are active", () => { + expect(filterIssues(issues, NO_FILTER)).toHaveLength(4); }); - it("encodes priority filter as an array", () => { - expect( - buildIssueListFilter({ ...NO_FILTER, priorityFilters: ["high", "urgent"] }), - ).toEqual({ priorities: ["high", "urgent"] }); + // --- Status --- + it("filters by status", () => { + const result = filterIssues(issues, { ...NO_FILTER, statusFilters: ["todo"] }); + expect(result.map((i) => i.id)).toEqual(["1", "4"]); }); - it("encodes assignee filters as id-only arrays", () => { - expect( - buildIssueListFilter({ - ...NO_FILTER, - assigneeFilters: [ - { type: "member", id: "u-1" }, - { type: "agent", id: "a-1" }, - ], - }), - ).toEqual({ assignee_ids: ["u-1", "a-1"] }); + // --- Priority --- + it("filters by priority", () => { + const result = filterIssues(issues, { ...NO_FILTER, priorityFilters: ["high", "urgent"] }); + expect(result.map((i) => i.id)).toEqual(["1", "4"]); }); - it("encodes 'no assignee' as include_no_assignee", () => { - expect(buildIssueListFilter({ ...NO_FILTER, includeNoAssignee: true })).toEqual({ - include_no_assignee: true, + // --- Assignee --- + it("filters by specific assignee", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + assigneeFilters: [{ type: "member", id: "u-1" }], }); + expect(result.map((i) => i.id)).toEqual(["1"]); }); - it("combines assignee ids with the no-assignee toggle", () => { - expect( - buildIssueListFilter({ - ...NO_FILTER, - assigneeFilters: [{ type: "member", id: "u-1" }], - includeNoAssignee: true, - }), - ).toEqual({ assignee_ids: ["u-1"], include_no_assignee: true }); + it("filters by 'No assignee' only", () => { + const result = filterIssues(issues, { ...NO_FILTER, includeNoAssignee: true }); + expect(result.map((i) => i.id)).toEqual(["3"]); }); - it("encodes creator and project filters", () => { - expect( - buildIssueListFilter({ - ...NO_FILTER, - creatorFilters: [{ type: "member", id: "u-2" }], - projectFilters: ["p-1", "p-2"], - includeNoProject: true, - }), - ).toEqual({ - creator_ids: ["u-2"], - project_ids: ["p-1", "p-2"], - include_no_project: true, + it("filters by assignee + No assignee combined", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + assigneeFilters: [{ type: "agent", id: "a-1" }], + includeNoAssignee: true, }); + expect(result.map((i) => i.id)).toEqual(["2", "3"]); }); - it("encodes label filters", () => { - expect( - buildIssueListFilter({ ...NO_FILTER, labelFilters: ["l-1", "l-2"] }), - ).toEqual({ label_ids: ["l-1", "l-2"] }); + it("hides assigned issues when only 'No assignee' is selected", () => { + const result = filterIssues(issues, { ...NO_FILTER, includeNoAssignee: true }); + expect(result.every((i) => !i.assignee_id)).toBe(true); }); - it("combines every filter dimension", () => { - expect( - buildIssueListFilter({ - priorityFilters: ["high"], - assigneeFilters: [{ type: "member", id: "u-1" }], - includeNoAssignee: false, - creatorFilters: [{ type: "agent", id: "a-1" }], - projectFilters: ["p-1"], - includeNoProject: false, - labelFilters: ["l-1"], - }), - ).toEqual({ - priorities: ["high"], - assignee_ids: ["u-1"], - creator_ids: ["a-1"], - project_ids: ["p-1"], - label_ids: ["l-1"], + // --- Creator --- + it("filters by creator", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + creatorFilters: [{ type: "agent", id: "a-1" }], }); + expect(result.map((i) => i.id)).toEqual(["2"]); + }); + + // --- Combinations --- + it("applies status + assignee filters together", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + statusFilters: ["todo"], + assigneeFilters: [{ type: "member", id: "u-1" }], + }); + expect(result.map((i) => i.id)).toEqual(["1"]); + }); + + it("applies status + priority + creator filters together", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + statusFilters: ["todo"], + priorityFilters: ["urgent"], + creatorFilters: [{ type: "member", id: "u-1" }], + }); + expect(result.map((i) => i.id)).toEqual(["4"]); + }); + + // --- Project --- + it("filters by specific project", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + projectFilters: ["p-1"], + }); + expect(result.map((i) => i.id)).toEqual(["1", "4"]); + }); + + it("filters by multiple projects", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + projectFilters: ["p-1", "p-2"], + }); + expect(result.map((i) => i.id)).toEqual(["1", "2", "4"]); + }); + + it("filters by 'No project' only", () => { + const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true }); + expect(result.map((i) => i.id)).toEqual(["3"]); + }); + + it("filters by project + No project combined", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + projectFilters: ["p-2"], + includeNoProject: true, + }); + expect(result.map((i) => i.id)).toEqual(["2", "3"]); + }); + + it("hides project issues when only 'No project' is selected", () => { + const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true }); + expect(result.every((i) => !i.project_id)).toBe(true); + }); + + it("applies status + project filters together", () => { + const result = filterIssues(issues, { + ...NO_FILTER, + statusFilters: ["todo"], + projectFilters: ["p-1"], + }); + expect(result.map((i) => i.id)).toEqual(["1", "4"]); }); }); diff --git a/packages/views/issues/utils/filter.ts b/packages/views/issues/utils/filter.ts index 3d5c164f1d..7779318ce9 100644 --- a/packages/views/issues/utils/filter.ts +++ b/packages/views/issues/utils/filter.ts @@ -1,57 +1,72 @@ -import type { IssuePriority } from "@multica/core/types"; -import type { IssueListFilter } from "@multica/core/issues/queries"; +import type { Issue, IssueStatus, IssuePriority } from "@multica/core/types"; import type { ActorFilterValue } from "@multica/core/issues/stores/view-store"; -/** - * Shape of the filter state held in the view store. Status is excluded — it - * controls which buckets are visible on the page, not which buckets are - * fetched (see {@link buildIssueListFilter} below). - */ -export interface IssueViewFilters { +export interface IssueFilters { + statusFilters: IssueStatus[]; priorityFilters: IssuePriority[]; assigneeFilters: ActorFilterValue[]; includeNoAssignee: boolean; creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; - labelFilters: string[]; } /** - * Translate the view-store filter state into the wire-shape filter accepted - * by `GET /api/issues`. Each filter type is OR'd within itself and AND'd - * with the others — same semantics the SQL layer enforces. + * Filter issues using positive selection model. + * Empty arrays = no filter (show all). Non-empty = show only matching. * - * Assignee and project filters can mix actor IDs with the "no assignee / - * project" toggle. The toggle is encoded as `include_no_*: true`, which the - * backend OR's against the id-list match. - * - * Returns an empty object when nothing is selected — callers can pass this - * to {@link issueListOptions} as a no-op filter. + * Assignee has a special "No assignee" toggle (includeNoAssignee): + * - When only includeNoAssignee is true → show only unassigned issues + * - When assigneeFilters has items → show only those assignees' issues + * - When both → show matching assignees + unassigned */ -export function buildIssueListFilter( - filters: IssueViewFilters, -): IssueListFilter { - const out: IssueListFilter = {}; - if (filters.priorityFilters.length > 0) { - out.priorities = [...filters.priorityFilters]; - } - // Assignee + creator filters carry an actor type alongside the id, but the - // backend keys assignment by id alone (member/agent ids never collide), so - // we send only the ids. - if (filters.assigneeFilters.length > 0) { - out.assignee_ids = filters.assigneeFilters.map((f) => f.id); - } - if (filters.includeNoAssignee) out.include_no_assignee = true; - if (filters.creatorFilters.length > 0) { - out.creator_ids = filters.creatorFilters.map((f) => f.id); - } - if (filters.projectFilters.length > 0) { - out.project_ids = [...filters.projectFilters]; - } - if (filters.includeNoProject) out.include_no_project = true; - if (filters.labelFilters.length > 0) { - out.label_ids = [...filters.labelFilters]; - } - return out; +export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] { + const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject } = filters; + const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee; + const hasProjectFilter = projectFilters.length > 0 || includeNoProject; + + return issues.filter((issue) => { + if (statusFilters.length > 0 && !statusFilters.includes(issue.status)) + return false; + + if (priorityFilters.length > 0 && !priorityFilters.includes(issue.priority)) + return false; + + if (hasAssigneeFilter) { + if (!issue.assignee_id) { + // Unassigned issue — show only if "No assignee" is checked + if (!includeNoAssignee) return false; + } else if (assigneeFilters.length > 0) { + // Assigned issue — show only if assignee is in the filter list + if (!assigneeFilters.some( + (f) => f.type === issue.assignee_type && f.id === issue.assignee_id, + )) return false; + } else { + // Only "No assignee" is checked, no specific assignees → hide assigned issues + return false; + } + } + + if ( + creatorFilters.length > 0 && + !creatorFilters.some( + (f) => f.type === issue.creator_type && f.id === issue.creator_id, + ) + ) { + return false; + } + + if (hasProjectFilter) { + if (!issue.project_id) { + if (!includeNoProject) return false; + } else if (projectFilters.length > 0) { + if (!projectFilters.includes(issue.project_id)) return false; + } else { + // Only "No project" is checked → hide issues that have a project + return false; + } + } + + return true; + }); } diff --git a/packages/views/my-issues/components/my-issues-page.tsx b/packages/views/my-issues/components/my-issues-page.tsx index 546700720a..03c9b2af13 100644 --- a/packages/views/my-issues/components/my-issues-page.tsx +++ b/packages/views/my-issues/components/my-issues-page.tsx @@ -11,6 +11,7 @@ import { useCurrentWorkspace } from "@multica/core/paths"; import { WorkspaceAvatar } from "../../workspace/workspace-avatar"; import { useQuery } from "@tanstack/react-query"; import { agentListOptions } from "@multica/core/workspace/queries"; +import { filterIssues } from "../../issues/utils/filter"; import { BOARD_STATUSES } from "@multica/core/issues/config"; import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context"; import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store"; @@ -54,38 +55,36 @@ export function MyIssuesPage() { const filter: MyIssuesFilter = useMemo(() => { if (!user) return {}; - // Server-side filtering — priority filters are added so paginated buckets - // reflect the user's selection even when matches sit past the first page. - // Status filtering stays client-side via `visibleStatuses`: each status - // bucket is fetched independently anyway, so hiding columns is a render - // concern, not a data-fetching one. - const base: MyIssuesFilter = - priorityFilters.length > 0 ? { priorities: [...priorityFilters] } : {}; switch (scope) { case "assigned": - return { ...base, assignee_ids: [user.id] }; + return { assignee_id: user.id }; case "created": - return { ...base, creator_ids: [user.id] }; + return { creator_id: user.id }; case "agents": - return { ...base, assignee_ids: myAgentIds }; + return { assignee_ids: myAgentIds }; default: - return { ...base, assignee_ids: [user.id] }; + return { assignee_id: user.id }; } - }, [scope, user, myAgentIds, priorityFilters]); + }, [scope, user, myAgentIds]); - // The "My Agents" tab is empty by definition when the user owns no agents. - // Without this guard the filter would resolve to `assignee_ids: []`, which - // the API client and query-key normalizer drop as falsy — the request would - // then go out unfiltered and surface other people's issues under "My Agents". - const myAgentsEmpty = scope === "agents" && myAgentIds.length === 0; - const { data: myIssues = [], isLoading: loading } = useQuery({ - ...myIssueListOptions(wsId, scope, filter), - enabled: !myAgentsEmpty, - }); - // Server-side filtering means `myIssues` already reflects the active scope - // + priority filter; rendering this directly avoids the pagination bug - // that the old client-side `filterIssues` step caused. - const issues = myIssues; + const { data: myIssues = [], isLoading: loading } = useQuery( + myIssueListOptions(wsId, scope, filter), + ); + + // Apply status/priority filters from view store + const issues = useMemo( + () => + filterIssues(myIssues, { + statusFilters, + priorityFilters, + assigneeFilters: [], + includeNoAssignee: false, + creatorFilters: [], + projectFilters: [], + includeNoProject: false, + }), + [myIssues, statusFilters, priorityFilters], + ); const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId)); diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index 08d2be818e..24db446fb4 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -1,7 +1,6 @@ "use client"; import { useMemo, useState, useCallback, useRef, useEffect } from "react"; -import { useStore } from "zustand"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, PanelRight, Pin, PinOff, Trash2, UserMinus } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; @@ -23,7 +22,7 @@ import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG, PROJECT_PRIORITY_ORDER, PR import { BOARD_STATUSES } from "@multica/core/issues/config"; import { createIssueViewStore } from "@multica/core/issues/stores/view-store"; import { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/view-store-context"; -import { buildIssueListFilter } from "../../issues/utils/filter"; +import { filterIssues } from "../../issues/utils/filter"; import { getProjectIssueMetrics } from "./project-issue-metrics"; import { ActorAvatar } from "../../common/actor-avatar"; import { AppLink, useNavigation } from "../../navigation"; @@ -100,7 +99,6 @@ function ProjectIssuesContent({ scope, filter, }: { - /** Issues already fetched server-side with this project + view-store filter applied. */ projectIssues: Issue[]; scope: string; filter: MyIssuesFilter; @@ -108,12 +106,15 @@ function ProjectIssuesContent({ const wsId = useWorkspaceId(); const viewMode = useViewStore((s) => s.viewMode); const statusFilters = useViewStore((s) => s.statusFilters); + const priorityFilters = useViewStore((s) => s.priorityFilters); + const assigneeFilters = useViewStore((s) => s.assigneeFilters); + const includeNoAssignee = useViewStore((s) => s.includeNoAssignee); + const creatorFilters = useViewStore((s) => s.creatorFilters); - // Server-side filtering means `projectIssues` already reflects priority / - // assignee / creator / label filters. Status filtering remains a render - // concern (each status bucket is fetched independently anyway, see - // visibleStatuses below). - const issues = projectIssues; + const issues = useMemo( + () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }), + [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], + ); const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId)); @@ -194,32 +195,9 @@ export function ProjectDetail({ projectId }: { projectId: string }) { const workspaceName = workspace?.name; const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId)); const projectScope = `project:${projectId}`; - // Read view-store filter state directly from the project's own view store - // (lives outside the ViewStoreProvider here). Merging it into the query - // filter means filter changes trigger a refetch under a fresh cache key - // — fixing the pagination bug where client-side filtering hid matches - // sitting past the first page (#1491). - const priorityFilters = useStore(projectViewStore, (s) => s.priorityFilters); - const assigneeFilters = useStore(projectViewStore, (s) => s.assigneeFilters); - const includeNoAssignee = useStore(projectViewStore, (s) => s.includeNoAssignee); - const creatorFilters = useStore(projectViewStore, (s) => s.creatorFilters); - const labelFilters = useStore(projectViewStore, (s) => s.labelFilters); const projectFilter = useMemo( - () => ({ - project_ids: [projectId], - ...buildIssueListFilter({ - priorityFilters, - assigneeFilters, - includeNoAssignee, - creatorFilters, - // Project filter is fixed by the page route — view-store project - // selections don't apply here. - projectFilters: [], - includeNoProject: false, - labelFilters, - }), - }), - [projectId, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters], + () => ({ project_id: projectId }), + [projectId], ); const { data: projectIssues = [] } = useQuery( myIssueListOptions(wsId, projectScope, projectFilter), @@ -601,7 +579,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) { - + 0 { - params.Set("label_ids", strings.Join(labels, ",")) - } path := "/api/issues" if len(params) > 0 { diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index 6634ee4bee..039e3cce26 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -8,7 +8,6 @@ import ( "errors" "log/slog" "net/http" - "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" @@ -154,14 +153,7 @@ func parseUUIDOrBadRequest(w http.ResponseWriter, s, fieldName string) (pgtype.U return u, true } -// parseUUIDSliceOrBadRequest mirrors parseUUIDOrBadRequest for slice inputs. -// An empty input slice returns nil (not a zero-length slice) so callers can -// distinguish "not provided" from "explicitly empty" — both look the same on -// the wire, but the SQL-layer narg check expects NULL for "no filter". func parseUUIDSliceOrBadRequest(w http.ResponseWriter, ids []string, fieldName string) ([]pgtype.UUID, bool) { - if len(ids) == 0 { - return nil, true - } uuids := make([]pgtype.UUID, len(ids)) for i, id := range ids { u, err := util.ParseUUID(id) @@ -174,26 +166,6 @@ func parseUUIDSliceOrBadRequest(w http.ResponseWriter, ids []string, fieldName s return uuids, true } -// splitCSV parses a comma-separated query-string value into a slice, trimming -// whitespace and dropping empty entries. Returns nil for an empty input so -// callers can distinguish "absent" from "present but empty". -func splitCSV(s string) []string { - if s == "" { - return nil - } - parts := strings.Split(s, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - if t := strings.TrimSpace(p); t != "" { - out = append(out, t) - } - } - if len(out) == 0 { - return nil - } - return out -} - // publish sends a domain event through the event bus. func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) { h.Bus.Publish(events.Event{ diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 55d433c6e7..10eee7d73d 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -609,75 +609,56 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { // Parse optional filter params. Malformed UUIDs in filters return 400 — // silently coercing them to a zero UUID would mask a client bug and let // the query return an empty result set (or worse, match a NULL row). - q := r.URL.Query() - - priorities := splitCSV(q.Get("priorities")) - if priorities == nil { - // Backwards compat: accept legacy single-value `priority`. - if p := q.Get("priority"); p != "" { - priorities = []string{p} + var priorityFilter pgtype.Text + if p := r.URL.Query().Get("priority"); p != "" { + priorityFilter = pgtype.Text{String: p, Valid: true} + } + var assigneeFilter pgtype.UUID + if a := r.URL.Query().Get("assignee_id"); a != "" { + id, ok := parseUUIDOrBadRequest(w, a, "assignee_id") + if !ok { + return } + assigneeFilter = id } - assigneeTypes := splitCSV(q.Get("assignee_types")) - assigneeIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("assignee_ids")), "assignee_ids") - if !ok { - return - } - if assigneeIds == nil { - // Backwards compat: accept legacy single-value `assignee_id`. - if a := q.Get("assignee_id"); a != "" { - id, ok := parseUUIDOrBadRequest(w, a, "assignee_id") - if !ok { - return + var assigneeIdsFilter []pgtype.UUID + if ids := r.URL.Query().Get("assignee_ids"); ids != "" { + for _, raw := range strings.Split(ids, ",") { + if s := strings.TrimSpace(raw); s != "" { + id, ok := parseUUIDOrBadRequest(w, s, "assignee_ids") + if !ok { + return + } + assigneeIdsFilter = append(assigneeIdsFilter, id) } - assigneeIds = []pgtype.UUID{id} } } - creatorIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("creator_ids")), "creator_ids") - if !ok { - return - } - if creatorIds == nil { - if c := q.Get("creator_id"); c != "" { - id, ok := parseUUIDOrBadRequest(w, c, "creator_id") - if !ok { - return - } - creatorIds = []pgtype.UUID{id} + var creatorFilter pgtype.UUID + if c := r.URL.Query().Get("creator_id"); c != "" { + id, ok := parseUUIDOrBadRequest(w, c, "creator_id") + if !ok { + return } + creatorFilter = id } - projectIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("project_ids")), "project_ids") - if !ok { - return - } - if projectIds == nil { - if p := q.Get("project_id"); p != "" { - id, ok := parseUUIDOrBadRequest(w, p, "project_id") - if !ok { - return - } - projectIds = []pgtype.UUID{id} + var projectFilter pgtype.UUID + if p := r.URL.Query().Get("project_id"); p != "" { + id, ok := parseUUIDOrBadRequest(w, p, "project_id") + if !ok { + return } + projectFilter = id } - labelIds, ok := parseUUIDSliceOrBadRequest(w, splitCSV(q.Get("label_ids")), "label_ids") - if !ok { - return - } - includeNoAssignee := pgtype.Bool{Bool: q.Get("include_no_assignee") == "true", Valid: true} - includeNoProject := pgtype.Bool{Bool: q.Get("include_no_project") == "true", Valid: true} // open_only=true returns all non-done/cancelled issues (no limit). - if q.Get("open_only") == "true" { + if r.URL.Query().Get("open_only") == "true" { issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{ - WorkspaceID: wsUUID, - Priorities: priorities, - AssigneeTypes: assigneeTypes, - AssigneeIds: assigneeIds, - IncludeNoAssignee: includeNoAssignee, - CreatorIds: creatorIds, - ProjectIds: projectIds, - IncludeNoProject: includeNoProject, - LabelIds: labelIds, + WorkspaceID: wsUUID, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -709,35 +690,32 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { limit := 100 offset := 0 - if l := q.Get("limit"); l != "" { + if l := r.URL.Query().Get("limit"); l != "" { if v, err := strconv.Atoi(l); err == nil { limit = v } } - if o := q.Get("offset"); o != "" { + if o := r.URL.Query().Get("offset"); o != "" { if v, err := strconv.Atoi(o); err == nil { offset = v } } var statusFilter pgtype.Text - if s := q.Get("status"); s != "" { + if s := r.URL.Query().Get("status"); s != "" { statusFilter = pgtype.Text{String: s, Valid: true} } issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ - WorkspaceID: wsUUID, - Limit: int32(limit), - Offset: int32(offset), - Status: statusFilter, - Priorities: priorities, - AssigneeTypes: assigneeTypes, - AssigneeIds: assigneeIds, - IncludeNoAssignee: includeNoAssignee, - CreatorIds: creatorIds, - ProjectIds: projectIds, - IncludeNoProject: includeNoProject, - LabelIds: labelIds, + WorkspaceID: wsUUID, + Limit: int32(limit), + Offset: int32(offset), + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -746,16 +724,13 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { // Get the true total count for pagination awareness. total, err := h.Queries.CountIssues(ctx, db.CountIssuesParams{ - WorkspaceID: wsUUID, - Status: statusFilter, - Priorities: priorities, - AssigneeTypes: assigneeTypes, - AssigneeIds: assigneeIds, - IncludeNoAssignee: includeNoAssignee, - CreatorIds: creatorIds, - ProjectIds: projectIds, - IncludeNoProject: includeNoProject, - LabelIds: labelIds, + WorkspaceID: wsUUID, + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, }) if err != nil { total = int64(len(issues)) diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index cf59365553..d33dc7eb41 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -97,51 +97,32 @@ const countIssues = `-- name: CountIssues :one SELECT count(*) FROM issue WHERE workspace_id = $1 AND ($2::text IS NULL OR status = $2) - AND ($3::text[] IS NULL OR priority = ANY($3::text[])) - AND ($4::text[] IS NULL OR assignee_type = ANY($4::text[])) - AND ( - ($5::uuid[] IS NULL AND NOT COALESCE($6::bool, false)) - OR assignee_id = ANY($5::uuid[]) - OR (COALESCE($6::bool, false) AND assignee_id IS NULL) - ) - AND ($7::uuid[] IS NULL OR creator_id = ANY($7::uuid[])) - AND ( - ($8::uuid[] IS NULL AND NOT COALESCE($9::bool, false)) - OR project_id = ANY($8::uuid[]) - OR (COALESCE($9::bool, false) AND project_id IS NULL) - ) - AND ($10::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY($10::uuid[]) - )) + AND ($3::text IS NULL OR priority = $3) + AND ($4::uuid IS NULL OR assignee_id = $4) + AND ($5::uuid[] IS NULL OR assignee_id = ANY($5::uuid[])) + AND ($6::uuid IS NULL OR creator_id = $6) + AND ($7::uuid IS NULL OR project_id = $7) ` type CountIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Status pgtype.Text `json:"status"` - Priorities []string `json:"priorities"` - AssigneeTypes []string `json:"assignee_types"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - IncludeNoAssignee pgtype.Bool `json:"include_no_assignee"` - CreatorIds []pgtype.UUID `json:"creator_ids"` - ProjectIds []pgtype.UUID `json:"project_ids"` - IncludeNoProject pgtype.Bool `json:"include_no_project"` - LabelIds []pgtype.UUID `json:"label_ids"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` } func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64, error) { row := q.db.QueryRow(ctx, countIssues, arg.WorkspaceID, arg.Status, - arg.Priorities, - arg.AssigneeTypes, + arg.Priority, + arg.AssigneeID, arg.AssigneeIds, - arg.IncludeNoAssignee, - arg.CreatorIds, - arg.ProjectIds, - arg.IncludeNoProject, - arg.LabelIds, + arg.CreatorID, + arg.ProjectID, ) var count int64 err := row.Scan(&count) @@ -478,41 +459,25 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND ($4::text IS NULL OR status = $4) - AND ($5::text[] IS NULL OR priority = ANY($5::text[])) - AND ($6::text[] IS NULL OR assignee_type = ANY($6::text[])) - AND ( - ($7::uuid[] IS NULL AND NOT COALESCE($8::bool, false)) - OR assignee_id = ANY($7::uuid[]) - OR (COALESCE($8::bool, false) AND assignee_id IS NULL) - ) - AND ($9::uuid[] IS NULL OR creator_id = ANY($9::uuid[])) - AND ( - ($10::uuid[] IS NULL AND NOT COALESCE($11::bool, false)) - OR project_id = ANY($10::uuid[]) - OR (COALESCE($11::bool, false) AND project_id IS NULL) - ) - AND ($12::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY($12::uuid[]) - )) + AND ($5::text IS NULL OR priority = $5) + AND ($6::uuid IS NULL OR assignee_id = $6) + AND ($7::uuid[] IS NULL OR assignee_id = ANY($7::uuid[])) + AND ($8::uuid IS NULL OR creator_id = $8) + AND ($9::uuid IS NULL OR project_id = $9) ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3 ` type ListIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` - Status pgtype.Text `json:"status"` - Priorities []string `json:"priorities"` - AssigneeTypes []string `json:"assignee_types"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - IncludeNoAssignee pgtype.Bool `json:"include_no_assignee"` - CreatorIds []pgtype.UUID `json:"creator_ids"` - ProjectIds []pgtype.UUID `json:"project_ids"` - IncludeNoProject pgtype.Bool `json:"include_no_project"` - LabelIds []pgtype.UUID `json:"label_ids"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` } type ListIssuesRow struct { @@ -541,14 +506,11 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI arg.Limit, arg.Offset, arg.Status, - arg.Priorities, - arg.AssigneeTypes, + arg.Priority, + arg.AssigneeID, arg.AssigneeIds, - arg.IncludeNoAssignee, - arg.CreatorIds, - arg.ProjectIds, - arg.IncludeNoProject, - arg.LabelIds, + arg.CreatorID, + arg.ProjectID, ) if err != nil { return nil, err @@ -593,37 +555,21 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') - AND ($2::text[] IS NULL OR priority = ANY($2::text[])) - AND ($3::text[] IS NULL OR assignee_type = ANY($3::text[])) - AND ( - ($4::uuid[] IS NULL AND NOT COALESCE($5::bool, false)) - OR assignee_id = ANY($4::uuid[]) - OR (COALESCE($5::bool, false) AND assignee_id IS NULL) - ) - AND ($6::uuid[] IS NULL OR creator_id = ANY($6::uuid[])) - AND ( - ($7::uuid[] IS NULL AND NOT COALESCE($8::bool, false)) - OR project_id = ANY($7::uuid[]) - OR (COALESCE($8::bool, false) AND project_id IS NULL) - ) - AND ($9::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY($9::uuid[]) - )) + AND ($2::text IS NULL OR priority = $2) + AND ($3::uuid IS NULL OR assignee_id = $3) + AND ($4::uuid[] IS NULL OR assignee_id = ANY($4::uuid[])) + AND ($5::uuid IS NULL OR creator_id = $5) + AND ($6::uuid IS NULL OR project_id = $6) ORDER BY position ASC, created_at DESC ` type ListOpenIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Priorities []string `json:"priorities"` - AssigneeTypes []string `json:"assignee_types"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - IncludeNoAssignee pgtype.Bool `json:"include_no_assignee"` - CreatorIds []pgtype.UUID `json:"creator_ids"` - ProjectIds []pgtype.UUID `json:"project_ids"` - IncludeNoProject pgtype.Bool `json:"include_no_project"` - LabelIds []pgtype.UUID `json:"label_ids"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` } type ListOpenIssuesRow struct { @@ -649,14 +595,11 @@ type ListOpenIssuesRow struct { func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) { rows, err := q.db.Query(ctx, listOpenIssues, arg.WorkspaceID, - arg.Priorities, - arg.AssigneeTypes, + arg.Priority, + arg.AssigneeID, arg.AssigneeIds, - arg.IncludeNoAssignee, - arg.CreatorIds, - arg.ProjectIds, - arg.IncludeNoProject, - arg.LabelIds, + arg.CreatorID, + arg.ProjectID, ) if err != nil { return nil, err diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 26308b8cbe..617e7fab84 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -5,24 +5,11 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) - AND (sqlc.narg('priorities')::text[] IS NULL OR priority = ANY(sqlc.narg('priorities')::text[])) - AND (sqlc.narg('assignee_types')::text[] IS NULL OR assignee_type = ANY(sqlc.narg('assignee_types')::text[])) - AND ( - (sqlc.narg('assignee_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_assignee')::bool, false)) - OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_assignee')::bool, false) AND assignee_id IS NULL) - ) - AND (sqlc.narg('creator_ids')::uuid[] IS NULL OR creator_id = ANY(sqlc.narg('creator_ids')::uuid[])) - AND ( - (sqlc.narg('project_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_project')::bool, false)) - OR project_id = ANY(sqlc.narg('project_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_project')::bool, false) AND project_id IS NULL) - ) - AND (sqlc.narg('label_ids')::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY(sqlc.narg('label_ids')::uuid[]) - )) + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) + AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) + AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) + AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) ORDER BY position ASC, created_at DESC LIMIT $2 OFFSET $3; @@ -91,48 +78,22 @@ SELECT id, workspace_id, title, description, status, priority, FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') - AND (sqlc.narg('priorities')::text[] IS NULL OR priority = ANY(sqlc.narg('priorities')::text[])) - AND (sqlc.narg('assignee_types')::text[] IS NULL OR assignee_type = ANY(sqlc.narg('assignee_types')::text[])) - AND ( - (sqlc.narg('assignee_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_assignee')::bool, false)) - OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_assignee')::bool, false) AND assignee_id IS NULL) - ) - AND (sqlc.narg('creator_ids')::uuid[] IS NULL OR creator_id = ANY(sqlc.narg('creator_ids')::uuid[])) - AND ( - (sqlc.narg('project_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_project')::bool, false)) - OR project_id = ANY(sqlc.narg('project_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_project')::bool, false) AND project_id IS NULL) - ) - AND (sqlc.narg('label_ids')::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY(sqlc.narg('label_ids')::uuid[]) - )) + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) + AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) + AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) + AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) ORDER BY position ASC, created_at DESC; -- name: CountIssues :one SELECT count(*) FROM issue WHERE workspace_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) - AND (sqlc.narg('priorities')::text[] IS NULL OR priority = ANY(sqlc.narg('priorities')::text[])) - AND (sqlc.narg('assignee_types')::text[] IS NULL OR assignee_type = ANY(sqlc.narg('assignee_types')::text[])) - AND ( - (sqlc.narg('assignee_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_assignee')::bool, false)) - OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_assignee')::bool, false) AND assignee_id IS NULL) - ) - AND (sqlc.narg('creator_ids')::uuid[] IS NULL OR creator_id = ANY(sqlc.narg('creator_ids')::uuid[])) - AND ( - (sqlc.narg('project_ids')::uuid[] IS NULL AND NOT COALESCE(sqlc.narg('include_no_project')::bool, false)) - OR project_id = ANY(sqlc.narg('project_ids')::uuid[]) - OR (COALESCE(sqlc.narg('include_no_project')::bool, false) AND project_id IS NULL) - ) - AND (sqlc.narg('label_ids')::uuid[] IS NULL OR EXISTS ( - SELECT 1 FROM issue_to_label il - WHERE il.issue_id = issue.id - AND il.label_id = ANY(sqlc.narg('label_ids')::uuid[]) - )); + AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) + AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) + AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) + AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')); -- name: ListChildIssues :many SELECT * FROM issue From b2fb39ed216f79796a9d502770a6cf8290e9c347 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:45:26 +0800 Subject: [PATCH 29/38] refactor(issues): flatten status group headers in list/board (#1783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the filled status chip (bg-warning/text-white etc.) from the list/board column headers — StatusIcon already carries the semantic color, so the chip duplicated it on the text background, and the bg-muted variants were nearly invisible against the muted/40 row background. Wrap the shared icon + label + count in a new StatusHeading component used by both list-view and board-column. Remove the now-unused badgeBg/badgeText fields from STATUS_CONFIG. Co-authored-by: Claude Opus 4.7 (1M context) --- packages/core/issues/config/status.ts | 16 ++++++-------- .../views/issues/components/board-column.tsx | 13 ++--------- packages/views/issues/components/index.ts | 1 + .../views/issues/components/list-view.tsx | 10 ++------- .../issues/components/status-heading.tsx | 22 +++++++++++++++++++ 5 files changed, 34 insertions(+), 28 deletions(-) create mode 100644 packages/views/issues/components/status-heading.tsx diff --git a/packages/core/issues/config/status.ts b/packages/core/issues/config/status.ts index ed7ab7c5a5..3d32e6808f 100644 --- a/packages/core/issues/config/status.ts +++ b/packages/core/issues/config/status.ts @@ -37,16 +37,14 @@ export const STATUS_CONFIG: Record< iconColor: string; hoverBg: string; dividerColor: string; - badgeBg: string; - badgeText: string; columnBg: string; } > = { - backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" }, - todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" }, - in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", badgeBg: "bg-warning", badgeText: "text-white", columnBg: "bg-warning/5" }, - in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10", dividerColor: "bg-success", badgeBg: "bg-success", badgeText: "text-white", columnBg: "bg-success/5" }, - done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", badgeBg: "bg-info", badgeText: "text-white", columnBg: "bg-info/5" }, - blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", badgeBg: "bg-destructive", badgeText: "text-white", columnBg: "bg-destructive/5" }, - cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", badgeBg: "bg-muted", badgeText: "text-muted-foreground", columnBg: "bg-muted/40" }, + backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" }, + todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" }, + in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10", dividerColor: "bg-warning", columnBg: "bg-warning/5" }, + in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10", dividerColor: "bg-success", columnBg: "bg-success/5" }, + done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10", dividerColor: "bg-info", columnBg: "bg-info/5" }, + blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10", dividerColor: "bg-destructive", columnBg: "bg-destructive/5" }, + cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent", dividerColor: "bg-muted-foreground/40", columnBg: "bg-muted/40" }, }; diff --git a/packages/views/issues/components/board-column.tsx b/packages/views/issues/components/board-column.tsx index d5244d60bd..d425ef40e6 100644 --- a/packages/views/issues/components/board-column.tsx +++ b/packages/views/issues/components/board-column.tsx @@ -16,7 +16,7 @@ import { import { STATUS_CONFIG } from "@multica/core/issues/config"; import { useModalStore } from "@multica/core/modals"; import { useViewStoreApi } from "@multica/core/issues/stores/view-store-context"; -import { StatusIcon } from "./status-icon"; +import { StatusHeading } from "./status-heading"; import { DraggableBoardCard } from "./board-card"; import type { ChildProgress } from "./list-row"; @@ -52,16 +52,7 @@ export function BoardColumn({ return (
- {/* Left: status badge + count */} -
- - - {cfg.label} - - - {totalCount ?? issueIds.length} - -
+ {/* Right: add + menu */}
diff --git a/packages/views/issues/components/index.ts b/packages/views/issues/components/index.ts index 75d932b809..9165acc32a 100644 --- a/packages/views/issues/components/index.ts +++ b/packages/views/issues/components/index.ts @@ -1,4 +1,5 @@ export { StatusIcon } from "./status-icon"; +export { StatusHeading } from "./status-heading"; export { PriorityIcon } from "./priority-icon"; export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker, LabelPicker } from "./pickers"; export { IssueDetail } from "./issue-detail"; diff --git a/packages/views/issues/components/list-view.tsx b/packages/views/issues/components/list-view.tsx index 0cef6e4ce3..d4d8f00bd0 100644 --- a/packages/views/issues/components/list-view.tsx +++ b/packages/views/issues/components/list-view.tsx @@ -8,12 +8,11 @@ import { Button } from "@multica/ui/components/ui/button"; import type { Issue, IssueStatus } from "@multica/core/types"; import { useLoadMoreByStatus } from "@multica/core/issues/mutations"; import type { MyIssuesFilter } from "@multica/core/issues/queries"; -import { STATUS_CONFIG } from "@multica/core/issues/config"; import { useModalStore } from "@multica/core/modals"; import { useViewStore } from "@multica/core/issues/stores/view-store-context"; import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store"; import { sortIssues } from "../utils/sort"; -import { StatusIcon } from "./status-icon"; +import { StatusHeading } from "./status-heading"; import { ListRow, type ChildProgress } from "./list-row"; import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; @@ -104,7 +103,6 @@ function StatusAccordionItem({ childProgressMap: Map; myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; }) { - const cfg = STATUS_CONFIG[status]; const selectedIds = useIssueSelectionStore((s) => s.selectedIds); const select = useIssueSelectionStore((s) => s.select); const deselect = useIssueSelectionStore((s) => s.deselect); @@ -140,11 +138,7 @@ function StatusAccordionItem({
- - - {cfg.label} - - {total} +
diff --git a/packages/views/issues/components/status-heading.tsx b/packages/views/issues/components/status-heading.tsx new file mode 100644 index 0000000000..32606013c1 --- /dev/null +++ b/packages/views/issues/components/status-heading.tsx @@ -0,0 +1,22 @@ +import type { IssueStatus } from "@multica/core/types"; +import { STATUS_CONFIG } from "@multica/core/issues/config"; +import { StatusIcon } from "./status-icon"; + +export function StatusHeading({ + status, + count, +}: { + status: IssueStatus; + count: number; +}) { + const cfg = STATUS_CONFIG[status]; + return ( +
+ + + {cfg.label} + + {count} +
+ ); +} From 2f793fb6feec5e821bd5430383ff3bd265a44787 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:46:03 +0800 Subject: [PATCH 30/38] docs(desktop-app): correct self-host callout to reflect build-time URLs (#1777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Released Desktop builds bake VITE_API_URL/VITE_WS_URL/VITE_APP_URL at build time and ship pointing at Multica Cloud — there is no in-app 'Connect to a self-hosted instance' button. Reported in multica-ai/multica#1768. - Replace the misleading callout in desktop-app.mdx (and zh) with the actual self-host path: build from source with custom env, or use web + CLI. Link to #1371 for the runtime-config feature. - Soften the corresponding 'Next steps' link in self-host-quickstart (and zh) so it no longer implies one-click Desktop self-host. --- apps/docs/content/docs/desktop-app.mdx | 19 ++++++++++++++++--- apps/docs/content/docs/desktop-app.zh.mdx | 19 ++++++++++++++++--- .../content/docs/self-host-quickstart.mdx | 2 +- .../content/docs/self-host-quickstart.zh.mdx | 2 +- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/docs/content/docs/desktop-app.mdx b/apps/docs/content/docs/desktop-app.mdx index 6b91655afd..a988c092f8 100644 --- a/apps/docs/content/docs/desktop-app.mdx +++ b/apps/docs/content/docs/desktop-app.mdx @@ -66,12 +66,25 @@ Grab the installer for your platform from the [Multica downloads page](https://m On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically. - -**Which backend Desktop connects to** is determined by the address you select at sign-in. It defaults to Multica Cloud; if you're running self-hosted, click "Connect to a self-hosted instance" on the first login screen and fill in your server address. + +**Released Desktop builds are pinned to Multica Cloud.** The backend, websocket, and web URLs are baked in at build time (`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`) — there is no in-app option to point Desktop at a self-hosted instance. To use Desktop against a self-hosted backend you need to build it yourself: + +```bash +git clone https://github.com/multica-ai/multica.git +cd multica +# Edit apps/desktop/.env.production: +# VITE_API_URL=https://api.your-domain +# VITE_WS_URL=wss://api.your-domain/ws +# VITE_APP_URL=https://your-domain +pnpm install +pnpm --filter @multica/desktop package +``` + +If you'd rather not build from source, the supported self-hosted path is **web frontend + CLI** — see [Self-host quickstart](/self-host-quickstart). Runtime backend configuration in Desktop is tracked in [issue #1371](https://github.com/multica-ai/multica/issues/1371). ## Next steps - [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop -- [Self-Host Quickstart](/self-host-quickstart) — connecting Desktop to a self-hosted backend +- [Self-Host Quickstart](/self-host-quickstart) — running your own backend (Desktop against self-host requires a custom build, see the callout above) - [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same) diff --git a/apps/docs/content/docs/desktop-app.zh.mdx b/apps/docs/content/docs/desktop-app.zh.mdx index 45656f43f0..30e6bd8034 100644 --- a/apps/docs/content/docs/desktop-app.zh.mdx +++ b/apps/docs/content/docs/desktop-app.zh.mdx @@ -66,12 +66,25 @@ macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的 安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。 - -**桌面版连哪个后端** 由登录时选的地址决定。默认连 Multica Cloud;如果你用自部署版本,在首次登录页点"连接到自部署实例"填你的 server 地址即可。 + +**发布版的 Desktop 是锁死连 Multica Cloud 的**。后端 / WebSocket / Web 前端 URL(`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`)在构建时就写死了,应用内**没有切换后端的入口**。要让 Desktop 连自部署后端,需要你自己从源码 build: + +```bash +git clone https://github.com/multica-ai/multica.git +cd multica +# 编辑 apps/desktop/.env.production: +# VITE_API_URL=https://api.your-domain +# VITE_WS_URL=wss://api.your-domain/ws +# VITE_APP_URL=https://your-domain +pnpm install +pnpm --filter @multica/desktop package +``` + +不想自己 build 的话,自部署的官方路径是 **Web 前端 + CLI**——见 [自部署快速上手](/self-host-quickstart)。Desktop 运行时切换后端的能力跟踪在 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。 ## 下一步 - [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程 -- [Self-Host Quickstart](/self-host-quickstart) —— Desktop 连自部署后端 +- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端(Desktop 连自部署需要自行构建,见上方提示) - [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样) diff --git a/apps/docs/content/docs/self-host-quickstart.mdx b/apps/docs/content/docs/self-host-quickstart.mdx index 451818f149..6acb73e188 100644 --- a/apps/docs/content/docs/self-host-quickstart.mdx +++ b/apps/docs/content/docs/self-host-quickstart.mdx @@ -116,4 +116,4 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5- - [Environment variables](/environment-variables) — full env reference - [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail - [Troubleshooting](/troubleshooting) — start here when things go wrong -- [Desktop app](/desktop-app) — the desktop app can also connect to your self-hosted backend +- [Desktop app](/desktop-app) — released Desktop builds connect to Multica Cloud only; using Desktop with self-host requires a custom build (see the callout in the desktop-app page) diff --git a/apps/docs/content/docs/self-host-quickstart.zh.mdx b/apps/docs/content/docs/self-host-quickstart.zh.mdx index 3d854ed8b6..8321de6484 100644 --- a/apps/docs/content/docs/self-host-quickstart.zh.mdx +++ b/apps/docs/content/docs/self-host-quickstart.zh.mdx @@ -115,4 +115,4 @@ multica setup self-host - [环境变量](/environment-variables) —— 完整 env 清单 - [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置 - [故障排查](/troubleshooting) —— 遇到问题先来这里 -- [桌面应用](/desktop-app) —— 桌面应用也能连你的自部署后端 +- [桌面应用](/desktop-app) —— 发布版 Desktop 只连 Multica Cloud;要让 Desktop 连自部署后端需要自行构建(详见 desktop-app 页的提示) From 0236e409e46b3198c518938f32115df04ae830d1 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:47:33 +0800 Subject: [PATCH 31/38] feat(issues): client-side label filter on the issues list (#1782) Adds a Label submenu to the workspace issues filter dropdown, backed by labelFilters in the shared issue view store. The filter is OR'd within itself (issue matches if it carries any of the selected labels) and AND'd with the existing status / priority / assignee / creator / project dimensions, mirroring the multi-select semantics already in place. Each label row renders via LabelChip for color parity with the sidebar picker, and each row's count comes from the same useIssueCounts pass that drives the other filter chips. Filtering stays client-side, consistent with all other filters today. The pagination caveat is a known limitation we'll revisit if real workspaces start hitting it; this PR intentionally does not change the fetch path. --- packages/core/issues/stores/view-store.ts | 11 ++ .../views/issues/components/issues-header.tsx | 100 +++++++++++++++++- .../issues/components/issues-page.test.tsx | 2 + .../views/issues/components/issues-page.tsx | 5 +- packages/views/issues/utils/filter.test.ts | 43 ++++++++ packages/views/issues/utils/filter.ts | 11 +- .../my-issues/components/my-issues-page.tsx | 1 + .../projects/components/project-detail.tsx | 5 +- 8 files changed, 172 insertions(+), 6 deletions(-) diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index c704ba62b4..e61b8f15e3 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -55,6 +55,7 @@ export interface IssueViewState { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; + labelFilters: string[]; sortBy: SortField; sortDirection: SortDirection; cardProperties: CardProperties; @@ -67,6 +68,7 @@ export interface IssueViewState { toggleCreatorFilter: (value: ActorFilterValue) => void; toggleProjectFilter: (projectId: string) => void; toggleNoProject: () => void; + toggleLabelFilter: (labelId: string) => void; hideStatus: (status: IssueStatus) => void; showStatus: (status: IssueStatus) => void; clearFilters: () => void; @@ -85,6 +87,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, + labelFilters: [], sortBy: "position", sortDirection: "asc", cardProperties: { @@ -147,6 +150,12 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue })), toggleNoProject: () => set((state) => ({ includeNoProject: !state.includeNoProject })), + toggleLabelFilter: (labelId) => + set((state) => ({ + labelFilters: state.labelFilters.includes(labelId) + ? state.labelFilters.filter((id) => id !== labelId) + : [...state.labelFilters, labelId], + })), hideStatus: (status) => set((state) => { // If no filter active, activate filter with all EXCEPT this one @@ -172,6 +181,7 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue creatorFilters: [], projectFilters: [], includeNoProject: false, + labelFilters: [], }), setSortBy: (field) => set({ sortBy: field }), setSortDirection: (dir) => set({ sortDirection: dir }), @@ -202,6 +212,7 @@ export const viewStorePersistOptions = (name: string) => ({ creatorFilters: state.creatorFilters, projectFilters: state.projectFilters, includeNoProject: state.includeNoProject, + labelFilters: state.labelFilters, sortBy: state.sortBy, sortDirection: state.sortDirection, cardProperties: state.cardProperties, diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index 878b6e9482..520f59b6a9 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -14,6 +14,7 @@ import { List, SignalHigh, SlidersHorizontal, + Tag, User, UserMinus, UserPen, @@ -49,8 +50,10 @@ import { useQuery } from "@tanstack/react-query"; import { useWorkspaceId } from "@multica/core/hooks"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; +import { labelListOptions } from "@multica/core/labels/queries"; import { ProjectIcon } from "../../projects/components/project-icon"; import { ActorAvatar } from "../../common/actor-avatar"; +import { LabelChip } from "../../labels/label-chip"; import { SORT_OPTIONS, CARD_PROPERTY_OPTIONS, @@ -94,6 +97,7 @@ function getActiveFilterCount(state: { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; + labelFilters: string[]; }) { let count = 0; if (state.statusFilters.length > 0) count++; @@ -101,6 +105,7 @@ function getActiveFilterCount(state: { if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++; if (state.creatorFilters.length > 0) count++; if (state.projectFilters.length > 0 || state.includeNoProject) count++; + if (state.labelFilters.length > 0) count++; return count; } @@ -111,6 +116,7 @@ function useIssueCounts(allIssues: Issue[]) { const assignee = new Map(); const creator = new Map(); const project = new Map(); + const label = new Map(); let noAssignee = 0; let noProject = 0; @@ -133,9 +139,15 @@ function useIssueCounts(allIssues: Issue[]) { } else { project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1); } + + if (issue.labels) { + for (const l of issue.labels) { + label.set(l.id, (label.get(l.id) ?? 0) + 1); + } + } } - return { status, priority, assignee, creator, noAssignee, project, noProject }; + return { status, priority, assignee, creator, noAssignee, project, noProject, label }; }, [allIssues]); } @@ -375,6 +387,70 @@ function ProjectSubContent({ ); } +// --------------------------------------------------------------------------- +// Label sub-menu content +// --------------------------------------------------------------------------- + +function LabelSubContent({ + counts, + selected, + onToggle, +}: { + counts: Map; + selected: string[]; + onToggle: (labelId: string) => void; +}) { + const [search, setSearch] = useState(""); + const wsId = useWorkspaceId(); + const { data: labels = [] } = useQuery(labelListOptions(wsId)); + const query = search.trim().toLowerCase(); + const filtered = labels.filter((l) => l.name.toLowerCase().includes(query)); + + return ( + <> +
+ setSearch(e.target.value)} + placeholder="Filter..." + className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" + autoFocus + /> +
+ +
+ {filtered.map((l) => { + const checked = selected.includes(l.id); + const count = counts.get(l.id) ?? 0; + return ( + onToggle(l.id)} + className={FILTER_ITEM_CLASS} + > + + + {count > 0 && ( + + {count} + + )} + + ); + })} + + {filtered.length === 0 && ( +
+ {search ? "No results" : "No labels yet"} +
+ )} +
+ + ); +} + // --------------------------------------------------------------------------- // IssuesHeader // --------------------------------------------------------------------------- @@ -391,6 +467,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { const creatorFilters = useViewStore((s) => s.creatorFilters); const projectFilters = useViewStore((s) => s.projectFilters); const includeNoProject = useViewStore((s) => s.includeNoProject); + const labelFilters = useViewStore((s) => s.labelFilters); const sortBy = useViewStore((s) => s.sortBy); const sortDirection = useViewStore((s) => s.sortDirection); const cardProperties = useViewStore((s) => s.cardProperties); @@ -407,6 +484,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { creatorFilters, projectFilters, includeNoProject, + labelFilters, }) > 0; const sortLabel = @@ -600,6 +678,26 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { + {/* Label */} + + + + Label + {labelFilters.length > 0 && ( + + {labelFilters.length} + + )} + + + + + + {/* Reset */} {hasActiveFilters && ( <> diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index d543443c04..54db9e80b8 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -106,6 +106,7 @@ const mockViewState = { creatorFilters: [] as { type: string; id: string }[], projectFilters: [] as string[], includeNoProject: false, + labelFilters: [] as string[], sortBy: "position" as const, sortDirection: "asc" as const, cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true }, @@ -118,6 +119,7 @@ const mockViewState = { toggleCreatorFilter: vi.fn(), toggleProjectFilter: vi.fn(), toggleNoProject: vi.fn(), + toggleLabelFilter: vi.fn(), hideStatus: vi.fn(), showStatus: vi.fn(), clearFilters: vi.fn(), diff --git a/packages/views/issues/components/issues-page.tsx b/packages/views/issues/components/issues-page.tsx index dc2901b270..6a43f65a0c 100644 --- a/packages/views/issues/components/issues-page.tsx +++ b/packages/views/issues/components/issues-page.tsx @@ -37,6 +37,7 @@ export function IssuesPage() { const creatorFilters = useIssueViewStore((s) => s.creatorFilters); const projectFilters = useIssueViewStore((s) => s.projectFilters); const includeNoProject = useIssueViewStore((s) => s.includeNoProject); + const labelFilters = useIssueViewStore((s) => s.labelFilters); // Clear filter state when switching between workspaces (URL-driven). useClearFiltersOnWorkspaceChange(useIssueViewStore, wsId); @@ -55,8 +56,8 @@ export function IssuesPage() { }, [allIssues, scope]); const issues = useMemo( - () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject }), - [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject], + () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters }), + [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters], ); // Fetch sub-issue progress from the backend so counts are accurate diff --git a/packages/views/issues/utils/filter.test.ts b/packages/views/issues/utils/filter.test.ts index adbb4faae7..bbf8913fa1 100644 --- a/packages/views/issues/utils/filter.test.ts +++ b/packages/views/issues/utils/filter.test.ts @@ -10,6 +10,7 @@ const NO_FILTER: IssueFilters = { creatorFilters: [], projectFilters: [], includeNoProject: false, + labelFilters: [], }; function makeIssue(overrides: Partial = {}): Issue { @@ -161,4 +162,46 @@ describe("filterIssues", () => { }); expect(result.map((i) => i.id)).toEqual(["1", "4"]); }); + + // --- Label --- + // Build a separate fixture for label tests so we can sprinkle labels onto + // specific rows without polluting the assignee/project test cases above. + const makeLabel = (id: string, name: string, color: string) => ({ + id, + name, + color, + workspace_id: "ws-1", + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + }); + const labelBug = makeLabel("lab-bug", "bug", "#ff0000"); + const labelFeat = makeLabel("lab-feat", "feature", "#00ff00"); + const labelP0 = makeLabel("lab-p0", "p0", "#0000ff"); + const labeledIssues: Issue[] = [ + makeIssue({ id: "L1", labels: [labelBug] }), + makeIssue({ id: "L2", labels: [labelFeat] }), + makeIssue({ id: "L3", labels: [labelBug, labelP0] }), + makeIssue({ id: "L4", labels: [] }), + makeIssue({ id: "L5" }), // labels field absent + ]; + + it("filters by a single label", () => { + const result = filterIssues(labeledIssues, { ...NO_FILTER, labelFilters: ["lab-bug"] }); + expect(result.map((i) => i.id)).toEqual(["L1", "L3"]); + }); + + it("filters by multiple labels with OR semantics", () => { + const result = filterIssues(labeledIssues, { + ...NO_FILTER, + labelFilters: ["lab-bug", "lab-feat"], + }); + expect(result.map((i) => i.id)).toEqual(["L1", "L2", "L3"]); + }); + + it("excludes issues with no labels when a label filter is active", () => { + const result = filterIssues(labeledIssues, { ...NO_FILTER, labelFilters: ["lab-bug"] }); + // L4 (empty labels) and L5 (missing labels field) must both be filtered out. + expect(result.map((i) => i.id)).not.toContain("L4"); + expect(result.map((i) => i.id)).not.toContain("L5"); + }); }); diff --git a/packages/views/issues/utils/filter.ts b/packages/views/issues/utils/filter.ts index 7779318ce9..5d03081223 100644 --- a/packages/views/issues/utils/filter.ts +++ b/packages/views/issues/utils/filter.ts @@ -9,6 +9,7 @@ export interface IssueFilters { creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; + labelFilters: string[]; } /** @@ -21,7 +22,7 @@ export interface IssueFilters { * - When both → show matching assignees + unassigned */ export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] { - const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject } = filters; + const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters } = filters; const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee; const hasProjectFilter = projectFilters.length > 0 || includeNoProject; @@ -67,6 +68,14 @@ export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] { } } + if (labelFilters.length > 0) { + // OR semantics within the filter: keep issues that carry any of the + // selected labels. Matches existing priority / project multi-select. + const issueLabels = issue.labels; + if (!issueLabels || issueLabels.length === 0) return false; + if (!issueLabels.some((l) => labelFilters.includes(l.id))) return false; + } + return true; }); } diff --git a/packages/views/my-issues/components/my-issues-page.tsx b/packages/views/my-issues/components/my-issues-page.tsx index 03c9b2af13..984186a84c 100644 --- a/packages/views/my-issues/components/my-issues-page.tsx +++ b/packages/views/my-issues/components/my-issues-page.tsx @@ -82,6 +82,7 @@ export function MyIssuesPage() { creatorFilters: [], projectFilters: [], includeNoProject: false, + labelFilters: [], }), [myIssues, statusFilters, priorityFilters], ); diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index 24db446fb4..ade4acfc1b 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -110,10 +110,11 @@ function ProjectIssuesContent({ const assigneeFilters = useViewStore((s) => s.assigneeFilters); const includeNoAssignee = useViewStore((s) => s.includeNoAssignee); const creatorFilters = useViewStore((s) => s.creatorFilters); + const labelFilters = useViewStore((s) => s.labelFilters); const issues = useMemo( - () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }), - [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], + () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters }), + [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters], ); const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId)); From fae108ebdc8658500c49e00cd7d2d705b8f0062f Mon Sep 17 00:00:00 2001 From: LinYushen Date: Tue, 28 Apr 2026 16:54:41 +0800 Subject: [PATCH 32/38] fix: refresh mention issue search results --- .../extensions/mention-suggestion.test.tsx | 63 ++++-- .../editor/extensions/mention-suggestion.tsx | 201 +++++++++++------- 2 files changed, 167 insertions(+), 97 deletions(-) diff --git a/packages/views/editor/extensions/mention-suggestion.test.tsx b/packages/views/editor/extensions/mention-suggestion.test.tsx index ef60322fe0..f505065f28 100644 --- a/packages/views/editor/extensions/mention-suggestion.test.tsx +++ b/packages/views/editor/extensions/mention-suggestion.test.tsx @@ -1,3 +1,5 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { createRef } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { workspaceKeys } from "@multica/core/workspace/queries"; import { issueKeys, PAGINATED_STATUSES } from "@multica/core/issues/queries"; @@ -19,7 +21,12 @@ vi.mock("@multica/core/api", () => ({ }, })); -import { createMentionSuggestion, type MentionItem } from "./mention-suggestion"; +import { + createMentionSuggestion, + MentionList, + type MentionListRef, + type MentionItem, +} from "./mention-suggestion"; function fakeQc(data: { members?: Array<{ user_id: string; name: string }>; @@ -66,34 +73,50 @@ describe("createMentionSuggestion", () => { expect(items.some((i) => i.type === "agent" && i.label === "Aegis")).toBe(true); }); - it("calls searchIssues with include_closed=true so done issues are findable", async () => { - const qc = fakeQc({}); - searchIssuesMock.mockResolvedValue({ issues: [], total: 0 }); + it("loads server issue matches into the popup when the list cache misses", async () => { + searchIssuesMock.mockResolvedValue({ + issues: [ + { + id: "i-1007", + identifier: "MUL-1007", + title: "多 Agent 协作探索", + status: "done", + }, + ], + total: 1, + }); - const config = createMentionSuggestion(qc); - config.items!({ query: "bug-xyz", editor: {} as never }); + render(); - // Wait past the 150ms debounce. - await new Promise((r) => setTimeout(r, 200)); + expect(screen.getByText("Searching...")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("MUL-1007")).toBeInTheDocument(); + }); + expect(screen.getByText("多 Agent 协作探索")).toBeInTheDocument(); expect(searchIssuesMock).toHaveBeenCalledWith( - expect.objectContaining({ q: "bug-xyz", include_closed: true }), + expect.objectContaining({ + q: "协作", + limit: 20, + include_closed: true, + }), ); }); - it("does not call searchIssues for an empty query", async () => { - const qc = fakeQc({}); - searchIssuesMock.mockResolvedValue({ issues: [], total: 0 }); + it("does not call searchIssues for an empty query", () => { + render(); - const config = createMentionSuggestion(qc); - config.items!({ query: "", editor: {} as never }); + expect(searchIssuesMock).not.toHaveBeenCalled(); + }); - await new Promise((r) => setTimeout(r, 200)); - // No call with an empty q (other tests' fire-and-forget closures may leak, - // so assert on the *content* of any call rather than absence). - for (const call of searchIssuesMock.mock.calls) { - expect(call[0].q).not.toBe(""); - } + it("captures Enter while the popup has no selectable items", () => { + const ref = createRef(); + + render(); + + expect( + ref.current?.onKeyDown({ event: new KeyboardEvent("keydown", { key: "Enter" }) }), + ).toBe(true); }); it("includes cached issues in the synchronous response", () => { diff --git a/packages/views/editor/extensions/mention-suggestion.tsx b/packages/views/editor/extensions/mention-suggestion.tsx index 000cad1f4d..6252a6fdc1 100644 --- a/packages/views/editor/extensions/mention-suggestion.tsx +++ b/packages/views/editor/extensions/mention-suggestion.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from "react"; @@ -15,7 +16,12 @@ import { getCurrentWsId } from "@multica/core/platform"; import { flattenIssueBuckets, issueKeys } from "@multica/core/issues/queries"; import { workspaceKeys } from "@multica/core/workspace/queries"; import { api } from "@multica/core/api"; -import type { Issue, ListIssuesCache, MemberWithUser, Agent } from "@multica/core/types"; +import type { + Issue, + ListIssuesCache, + MemberWithUser, + Agent, +} from "@multica/core/types"; import { ActorAvatar } from "../../common/actor-avatar"; import { StatusIcon } from "../../issues/components/status-icon"; import { Badge } from "@multica/ui/components/ui/badge"; @@ -38,6 +44,7 @@ export interface MentionItem { interface MentionListProps { items: MentionItem[]; + query: string; command: (item: MentionItem) => void; } @@ -76,14 +83,100 @@ function groupItems(items: MentionItem[]): MentionGroup[] { // MentionList — the popup rendered inside the editor // --------------------------------------------------------------------------- -const MentionList = forwardRef( - function MentionList({ items, command }, ref) { +const MAX_ITEMS = 20; +const SERVER_ISSUE_SEARCH_LIMIT = 20; +const SERVER_SEARCH_DEBOUNCE_MS = 150; + +function mentionItemKey(item: MentionItem): string { + return `${item.type}:${item.id}`; +} + +function mergeMentionItems( + syncItems: MentionItem[], + serverIssueItems: MentionItem[], +): MentionItem[] { + const seen = new Set(); + const merged: MentionItem[] = []; + + for (const item of [...syncItems, ...serverIssueItems]) { + const key = mentionItemKey(item); + if (seen.has(key)) continue; + seen.add(key); + merged.push(item); + } + + return merged; +} + +export const MentionList = forwardRef( + function MentionList({ items, query, command }, ref) { const [selectedIndex, setSelectedIndex] = useState(0); + const [serverIssueItems, setServerIssueItems] = useState([]); + const [isSearchingIssues, setIsSearchingIssues] = useState(false); + const [searchedIssueQuery, setSearchedIssueQuery] = useState(""); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + const normalizedQuery = query.trim(); + + useEffect(() => { + const q = normalizedQuery; + setServerIssueItems([]); + + if (!q) { + setIsSearchingIssues(false); + setSearchedIssueQuery(""); + return; + } + + const wsId = getCurrentWsId(); + if (!wsId) { + setIsSearchingIssues(false); + setSearchedIssueQuery(q); + return; + } + + let cancelled = false; + const controller = new AbortController(); + setIsSearchingIssues(true); + + const timer = setTimeout(() => { + void (async () => { + try { + const res = await api.searchIssues({ + q, + limit: SERVER_ISSUE_SEARCH_LIMIT, + include_closed: true, + signal: controller.signal, + }); + if (!cancelled && !controller.signal.aborted) { + setServerIssueItems(res.issues.map(issueToMention)); + } + } catch { + // Aborted or network error: keep the synchronous cache results. + } finally { + if (!cancelled && !controller.signal.aborted) { + setSearchedIssueQuery(q); + setIsSearchingIssues(false); + } + } + })(); + }, SERVER_SEARCH_DEBOUNCE_MS); + + return () => { + cancelled = true; + clearTimeout(timer); + controller.abort(); + }; + }, [normalizedQuery]); + + const displayItems = useMemo(() => { + const currentServerIssueItems = + searchedIssueQuery === normalizedQuery ? serverIssueItems : []; + return mergeMentionItems(items, currentServerIssueItems).slice(0, MAX_ITEMS); + }, [items, normalizedQuery, searchedIssueQuery, serverIssueItems]); useEffect(() => { setSelectedIndex(0); - }, [items]); + }, [displayItems]); useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); @@ -91,23 +184,28 @@ const MentionList = forwardRef( const selectItem = useCallback( (index: number) => { - const item = items[index]; + const item = displayItems[index]; if (item) command(item); }, - [items, command], + [displayItems, command], ); useImperativeHandle(ref, () => ({ onKeyDown: ({ event }) => { if (event.key === "ArrowUp") { - setSelectedIndex((i) => (i + items.length - 1) % items.length); + if (displayItems.length === 0) return true; + setSelectedIndex( + (i) => (i + displayItems.length - 1) % displayItems.length, + ); return true; } if (event.key === "ArrowDown") { - setSelectedIndex((i) => (i + 1) % items.length); + if (displayItems.length === 0) return true; + setSelectedIndex((i) => (i + 1) % displayItems.length); return true; } if (event.key === "Enter") { + if (displayItems.length === 0) return true; selectItem(selectedIndex); return true; } @@ -115,15 +213,19 @@ const MentionList = forwardRef( }, })); - if (items.length === 0) { + if (displayItems.length === 0) { + const isWaitingForServer = + normalizedQuery !== "" && + (isSearchingIssues || searchedIssueQuery !== normalizedQuery); + return (
- No results + {isWaitingForServer ? "Searching..." : "No results"}
); } - const groups = groupItems(items); + const groups = groupItems(displayItems); // Build a flat index mapping: globalIndex → item let globalIndex = 0; @@ -231,18 +333,13 @@ function issueToMention(i: Pick }; } -const MAX_ITEMS = 15; - export function createMentionSuggestion(qc: QueryClient): Omit< SuggestionOptions, "editor" > { - // Per-editor state lives in this closure so multiple ContentEditor instances - // (e.g. comment input + reply box) don't abort each other's searches. + // Renderer/popup instances live in this closure so each ContentEditor owns + // its own TipTap suggestion popup lifecycle. let renderer: ReactRenderer | null = null; - let activeCommand: ((item: MentionItem) => void) | null = null; - let searchSeq = 0; - let searchAbort: AbortController | null = null; let popup: HTMLDivElement | null = null; function buildSyncItems(query: string): MentionItem[] { @@ -276,8 +373,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit< .filter((a) => !a.archived_at && a.name.toLowerCase().includes(q)) .map((a) => ({ id: a.id, label: a.name, type: "agent" as const })); - // Cached issues give an instant first paint; the server search below - // adds done/cancelled and any other matches not in the local cache. + // Cached issues give an instant first paint; MentionList adds server + // matches for done/cancelled and any other issues not in this cache. const issueItems: MentionItem[] = cachedIssues .filter( (i) => @@ -289,68 +386,23 @@ export function createMentionSuggestion(qc: QueryClient): Omit< return [...allItem, ...memberItems, ...agentItems, ...issueItems]; } - function startServerIssueSearch(query: string, syncItems: MentionItem[]) { - // Supersede any in-flight search; the next-arrived response wins. - if (searchAbort) searchAbort.abort(); - const mySeq = ++searchSeq; - const wsId = getCurrentWsId(); - if (!wsId) return; - - void (async () => { - // Debounce: skip the fetch if a newer keystroke arrives within 150ms. - await new Promise((r) => setTimeout(r, 150)); - if (mySeq !== searchSeq) return; - - const controller = new AbortController(); - searchAbort = controller; - try { - const res = await api.searchIssues({ - q: query, - limit: 10, - include_closed: true, - signal: controller.signal, - }); - if (mySeq !== searchSeq) return; - if (!renderer || !activeCommand) return; - - const existingIssueIds = new Set( - syncItems.filter((i) => i.type === "issue").map((i) => i.id), - ); - const extraIssueItems = res.issues - .map(issueToMention) - .filter((i) => !existingIssueIds.has(i.id)); - if (extraIssueItems.length === 0) return; - - const merged = [...syncItems, ...extraIssueItems].slice(0, MAX_ITEMS); - renderer.updateProps({ items: merged, command: activeCommand }); - } catch { - // Aborted or network error: nothing to do — sync items remain. - } - })(); - } - return { items: ({ query }) => { const syncItems = buildSyncItems(query); - // Empty query has no server search — cached issues are enough, and - // we still bump the seq to cancel any pending fetch from a prior key. - if (query === "") { - if (searchAbort) searchAbort.abort(); - ++searchSeq; - } else { - startServerIssueSearch(query, syncItems); - } - return syncItems.slice(0, MAX_ITEMS); + return syncItems; }, render: () => { return { onStart: (props: SuggestionProps) => { renderer = new ReactRenderer(MentionList, { - props: { items: props.items, command: props.command }, + props: { + items: props.items, + query: props.query, + command: props.command, + }, editor: props.editor, }); - activeCommand = props.command; popup = document.createElement("div"); popup.style.position = "fixed"; @@ -364,9 +416,9 @@ export function createMentionSuggestion(qc: QueryClient): Omit< onUpdate: (props: SuggestionProps) => { renderer?.updateProps({ items: props.items, + query: props.query, command: props.command, }); - activeCommand = props.command; if (popup) updatePosition(popup, props.clientRect); }, @@ -404,13 +456,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit< function cleanup() { renderer?.destroy(); renderer = null; - activeCommand = null; popup?.remove(); popup = null; - // Cancel any in-flight server search; its result would target a - // destroyed renderer. - if (searchAbort) searchAbort.abort(); - ++searchSeq; } }, }; From c366cf2ba1455b3ea2bb4e209bd143a46a6a995d Mon Sep 17 00:00:00 2001 From: LinYushen Date: Tue, 28 Apr 2026 17:03:46 +0800 Subject: [PATCH 33/38] feat(agent): add Kiro CLI ACP runtime (#1780) * feat(agent): add kiro cli acp runtime * fix(agent): align kiro acp prompt and notifications * chore(agent): clarify kiro acp args compatibility --- CLI_AND_DAEMON.md | 6 + README.md | 11 +- SELF_HOSTING.md | 2 + apps/docs/content/docs/agents-create.mdx | 6 +- apps/docs/content/docs/agents-create.zh.mdx | 6 +- .../docs/content/docs/cli/installation.zh.mdx | 4 +- apps/docs/content/docs/cli/reference.zh.mdx | 12 + apps/docs/content/docs/cloud-quickstart.mdx | 4 +- .../docs/content/docs/cloud-quickstart.zh.mdx | 4 +- apps/docs/content/docs/daemon-runtimes.mdx | 4 +- apps/docs/content/docs/daemon-runtimes.zh.mdx | 4 +- apps/docs/content/docs/how-multica-works.mdx | 2 +- .../content/docs/how-multica-works.zh.mdx | 2 +- apps/docs/content/docs/index.mdx | 2 +- apps/docs/content/docs/index.zh.mdx | 2 +- apps/docs/content/docs/providers.mdx | 16 +- apps/docs/content/docs/providers.zh.mdx | 16 +- apps/docs/content/docs/skills.mdx | 2 +- apps/docs/content/docs/skills.zh.mdx | 2 +- apps/docs/content/docs/tasks.mdx | 4 +- apps/docs/content/docs/tasks.zh.mdx | 4 +- docs/product-overview.md | 6 +- .../views/onboarding/steps/step-welcome.tsx | 2 + .../runtimes/components/provider-logo.tsx | 13 + server/internal/daemon/config.go | 11 +- server/internal/daemon/execenv/context.go | 5 + server/internal/daemon/execenv/execenv.go | 2 +- .../internal/daemon/execenv/execenv_test.go | 57 +++ .../internal/daemon/execenv/runtime_config.go | 7 +- server/internal/daemon/local_skills.go | 3 + server/internal/daemon/local_skills_test.go | 33 +- server/pkg/agent/agent.go | 11 +- server/pkg/agent/agent_test.go | 2 +- server/pkg/agent/hermes.go | 113 +++++- server/pkg/agent/hermes_test.go | 78 ++++ server/pkg/agent/kiro.go | 340 ++++++++++++++++++ server/pkg/agent/kiro_test.go | 284 +++++++++++++++ server/pkg/agent/models.go | 22 +- server/pkg/agent/models_test.go | 15 + 39 files changed, 1040 insertions(+), 79 deletions(-) create mode 100644 server/pkg/agent/kiro.go create mode 100644 server/pkg/agent/kiro_test.go diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index c8c43fb726..1fdf2a4c30 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -146,6 +146,8 @@ The daemon auto-detects these AI CLIs on your PATH: | Gemini | `gemini` | Google's coding agent | | [Pi](https://pi.dev/) | `pi` | Pi coding agent | | [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent | +| Kimi | `kimi` | Moonshot coding agent | +| Kiro CLI | `kiro-cli` | Kiro ACP coding agent | You need at least one installed. The daemon registers each detected CLI as an available runtime. @@ -193,6 +195,10 @@ Agent-specific overrides: | `MULTICA_PI_MODEL` | Override the Pi model used | | `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary | | `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used | +| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary | +| `MULTICA_KIMI_MODEL` | Override the Kimi model used | +| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary | +| `MULTICA_KIRO_MODEL` | Override the Kiro model used | ### Self-Hosted Server diff --git a/README.md b/README.md index edbe069a60..817be3ded9 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously. -No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**. +No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.

Multica board view @@ -98,7 +98,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon multica setup # Configure, authenticate, and start the daemon ``` -The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH. +The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH. ### 2. Verify your runtime @@ -108,7 +108,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes* ### 3. Create an agent -Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments. +Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments. ### 4. Assign your first task @@ -162,7 +162,8 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference │ Agent Daemon │ runs on your machine └──────────────┘ (Claude Code, Codex, OpenCode, OpenClaw, Hermes, Gemini, - Pi, Cursor Agent) + Pi, Cursor Agent, Kimi, + Kiro CLI) ``` | Layer | Stack | @@ -170,7 +171,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference | Frontend | Next.js 16 (App Router) | | Backend | Go (Chi router, sqlc, gorilla/websocket) | | Database | PostgreSQL 17 with pgvector | -| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent | +| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI | ## Development diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 40dc5cff16..7e86fb9e42 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -98,6 +98,8 @@ You also need at least one AI agent CLI installed: - Gemini (`gemini` on PATH) - [Pi](https://pi.dev/) (`pi` on PATH) - [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH) +- Kimi (`kimi` on PATH) +- Kiro CLI (`kiro-cli` on PATH) ### b) One-command setup diff --git a/apps/docs/content/docs/agents-create.mdx b/apps/docs/content/docs/agents-create.mdx index 3a9e48a1a5..2d4a7af5c5 100644 --- a/apps/docs/content/docs/agents-create.mdx +++ b/apps/docs/content/docs/agents-create.mdx @@ -21,7 +21,7 @@ The form has only two required fields: **name** (unique within the workspace) an ## Pick an AI coding tool -Each runtime is backed by a specific AI coding tool. Multica supports 10 of them. The most common choices: +Each runtime is backed by a specific AI coding tool. Multica supports 11 of them. The most common choices: | Tool | Good for | |---|---| @@ -31,7 +31,7 @@ Each runtime is backed by a specific AI coding tool. Multica supports 10 of them | **Copilot** | Teams leveraging their GitHub account entitlements | | **Gemini** | Users in the Google ecosystem | -The other five (Hermes, Kimi, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers). +The other six (Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers). ## Writing system instructions @@ -123,5 +123,5 @@ Archived agents can't be assigned new tasks. ## Next steps - [Skills](/skills) — attach knowledge packs to an agent -- [AI coding tools comparison](/providers) — full capability matrix across all 10 tools +- [AI coding tools comparison](/providers) — full capability matrix across all 11 tools - [Assigning issues to agents](/assigning-issues) — put your new agent to work diff --git a/apps/docs/content/docs/agents-create.zh.mdx b/apps/docs/content/docs/agents-create.zh.mdx index fb0eac6f3a..57eb645c7e 100644 --- a/apps/docs/content/docs/agents-create.zh.mdx +++ b/apps/docs/content/docs/agents-create.zh.mdx @@ -21,7 +21,7 @@ multica agent create ## 选一款 AI 编程工具 -运行时背后是一款具体的 AI 编程工具。Multica 支持 10 款,最常用的几款: +运行时背后是一款具体的 AI 编程工具。Multica 支持 11 款,最常用的几款: | 工具 | 适合 | |---|---| @@ -31,7 +31,7 @@ multica agent create | **Copilot** | 用 GitHub 账号权益的团队 | | **Gemini** | Google 生态用户 | -另外 5 款(Hermes、Kimi、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。 +另外 6 款(Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。 ## 写系统指令 @@ -123,5 +123,5 @@ claude --model --max-turns 100 --append-system-prompt "always respond in ## 下一步 - [Skills](/skills) —— 给智能体挂专业知识包 -- [AI 编程工具对照](/providers) —— 10 款工具的完整能力差别 +- [AI 编程工具对照](/providers) —— 11 款工具的完整能力差别 - [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来 diff --git a/apps/docs/content/docs/cli/installation.zh.mdx b/apps/docs/content/docs/cli/installation.zh.mdx index b5c8f02ff4..81ae5bbada 100644 --- a/apps/docs/content/docs/cli/installation.zh.mdx +++ b/apps/docs/content/docs/cli/installation.zh.mdx @@ -78,7 +78,7 @@ multica daemon status Confirm: 1. Status is `running` -2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`) +2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, `kiro`, or `pi`) 3. At least one workspace is being watched If the agents list is empty, install at least one supported AI agent CLI: @@ -88,6 +88,8 @@ If the agents list is empty, install at least one supported AI agent CLI: - OpenCode (`opencode`) - OpenClaw (`openclaw`) - Hermes (`hermes`) +- Kimi (`kimi`) +- Kiro CLI (`kiro-cli`) Then restart the daemon: diff --git a/apps/docs/content/docs/cli/reference.zh.mdx b/apps/docs/content/docs/cli/reference.zh.mdx index f43c534cd3..f87579c815 100644 --- a/apps/docs/content/docs/cli/reference.zh.mdx +++ b/apps/docs/content/docs/cli/reference.zh.mdx @@ -92,6 +92,10 @@ The daemon auto-detects these AI CLIs on your PATH: | OpenCode | `opencode` | Open-source coding agent | | OpenClaw | `openclaw` | Open-source coding agent | | Hermes | `hermes` | Nous Research coding agent | +| Kimi | `kimi` | Moonshot coding agent | +| Kiro CLI | `kiro-cli` | Kiro ACP coding agent | +| Pi | `pi` | Inflection coding agent | +| Cursor Agent | `cursor-agent` | Cursor coding agent | You need at least one installed. The daemon registers each detected CLI as an available runtime. @@ -134,6 +138,14 @@ Agent-specific overrides: | `MULTICA_HERMES_MODEL` | Override the Hermes model used | | `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary | | `MULTICA_GEMINI_MODEL` | Override the Gemini model used | +| `MULTICA_PI_PATH` | Custom path to the `pi` binary | +| `MULTICA_PI_MODEL` | Override the Pi model used | +| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary | +| `MULTICA_CURSOR_MODEL` | Override the Cursor model used | +| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary | +| `MULTICA_KIMI_MODEL` | Override the Kimi model used | +| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary | +| `MULTICA_KIRO_MODEL` | Override the Kiro model used | ### Self-Hosted Server diff --git a/apps/docs/content/docs/cloud-quickstart.mdx b/apps/docs/content/docs/cloud-quickstart.mdx index bdd1725261..01d86193d1 100644 --- a/apps/docs/content/docs/cloud-quickstart.mdx +++ b/apps/docs/content/docs/cloud-quickstart.mdx @@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout"; This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes. -One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present. +One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present. ## 1. Create an account @@ -114,6 +114,6 @@ The web UI updates in **real time** (via WebSocket) — no refresh needed. - [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean - [Tasks](/tasks) — task lifecycle and retry rules -- [AI coding tools compared](/providers) — capability differences across the 10 tools +- [AI coding tools compared](/providers) — capability differences across the 11 tools - [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself - [Self-host quickstart](/self-host-quickstart) — run your own backend diff --git a/apps/docs/content/docs/cloud-quickstart.zh.mdx b/apps/docs/content/docs/cloud-quickstart.zh.mdx index 3a2c4d77c1..25baabda9a 100644 --- a/apps/docs/content/docs/cloud-quickstart.zh.mdx +++ b/apps/docs/content/docs/cloud-quickstart.zh.mdx @@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout"; 这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。 -前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。 +前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。 ## 1. 注册账号 @@ -114,6 +114,6 @@ Web 界面会**实时**(通过 WebSocket)显示进度——不需要刷新 - [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念 - [执行任务](/tasks) —— 任务生命周期、重试规则 -- [AI 编程工具对照](/providers) —— 10 款工具的能力差异 +- [AI 编程工具对照](/providers) —— 11 款工具的能力差异 - [桌面应用](/desktop-app) —— 不想自己跑守护进程的话 - [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套 diff --git a/apps/docs/content/docs/daemon-runtimes.mdx b/apps/docs/content/docs/daemon-runtimes.mdx index 9aac2c25a3..b60c07f5bc 100644 --- a/apps/docs/content/docs/daemon-runtimes.mdx +++ b/apps/docs/content/docs/daemon-runtimes.mdx @@ -21,7 +21,7 @@ multica daemon start On startup it does four things: 1. Reads the credentials saved when you logged in -2. Detects AI coding tools installed on your `PATH` (10 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi)) +2. Detects AI coding tools installed on your `PATH` (11 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi)) 3. Registers itself with the server, along with a runtime for each detected tool 4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds** @@ -108,4 +108,4 @@ More scenarios in [Troubleshooting](/troubleshooting). ## Next - [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up -- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools +- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools diff --git a/apps/docs/content/docs/daemon-runtimes.zh.mdx b/apps/docs/content/docs/daemon-runtimes.zh.mdx index 7553b75ed3..3de87d2a55 100644 --- a/apps/docs/content/docs/daemon-runtimes.zh.mdx +++ b/apps/docs/content/docs/daemon-runtimes.zh.mdx @@ -21,7 +21,7 @@ multica daemon start 启动后它会做四件事: 1. 读取你登录时保存的凭证 -2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 10 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)) +2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)) 3. 向服务器注册自己,以及每款检测到的工具对应的运行时 4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳** @@ -108,4 +108,4 @@ Multica 对并发有两层限额: ## 下一步 - [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期 -- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照 +- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照 diff --git a/apps/docs/content/docs/how-multica-works.mdx b/apps/docs/content/docs/how-multica-works.mdx index 413c84392c..659c34ca29 100644 --- a/apps/docs/content/docs/how-multica-works.mdx +++ b/apps/docs/content/docs/how-multica-works.mdx @@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro - **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks. - **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds. -- **AI coding tools** — one of the ten (or several in parallel): [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work. +- **AI coding tools** — one of the eleven (or several in parallel): [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work. Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud. diff --git a/apps/docs/content/docs/how-multica-works.zh.mdx b/apps/docs/content/docs/how-multica-works.zh.mdx index 705889bfac..280eba860e 100644 --- a/apps/docs/content/docs/how-multica-works.zh.mdx +++ b/apps/docs/content/docs/how-multica-works.zh.mdx @@ -13,7 +13,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台—— - **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub,把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。 - **守护进程**(daemon)——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server,开始每 3 秒领一次任务、每 15 秒发一次心跳。 -- **AI 编程工具**——[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 十款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。 +- **AI 编程工具**——[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 11 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。 工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用;Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。 diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index ad94c18002..ff2e9a731e 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica. Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model: -- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Ten are built in today: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine. +- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Eleven are built in today: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine. **Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified. diff --git a/apps/docs/content/docs/index.zh.mdx b/apps/docs/content/docs/index.zh.mdx index a298044dfc..a74ddc311a 100644 --- a/apps/docs/content/docs/index.zh.mdx +++ b/apps/docs/content/docs/index.zh.mdx @@ -13,7 +13,7 @@ Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在 智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式: -- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置十种:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。 +- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。 **云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。 diff --git a/apps/docs/content/docs/providers.mdx b/apps/docs/content/docs/providers.mdx index 811d159fbf..a2ece008b1 100644 --- a/apps/docs/content/docs/providers.mdx +++ b/apps/docs/content/docs/providers.mdx @@ -1,11 +1,11 @@ --- title: AI coding tools matrix -description: Multica supports 10 AI coding tools; they implement the same interface, but the capability details diverge significantly. +description: Multica supports 11 AI coding tools; they implement the same interface, but the capability details diverge significantly. --- import { Callout } from "fumadocs-ui/components/callout"; -Multica ships with built-in support for **10 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix. +Multica ships with built-in support for **11 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix. For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create). @@ -20,6 +20,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu | **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | Static | | **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback) | Dynamic discovery | | **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | Dynamic discovery | +| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | Dynamic discovery | | **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | Dynamic discovery | | **OpenClaw** | Open source | ✅ | ❌ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task | | **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery | @@ -28,7 +29,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu ### Claude Code -From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 10 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key. +From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 11 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key. ### Codex @@ -54,6 +55,10 @@ From Nous Research. Uses the ACP protocol (shares a transport with Kimi). Sessio From Moonshot, aimed at the Chinese market. Shares the ACP protocol with Hermes, but the skill path `.kimi/skills/` is Kimi CLI's native discovery mechanism — different from Hermes's fallback. +### Kiro CLI + +From Amazon. Uses ACP over stdio via `kiro-cli acp`. Session resumption works through ACP `session/load`, model selection works through `session/set_model`, and skills are copied into `.kiro/skills/` for native project-level discovery. + ### OpenCode From SST, open source. Dynamically discovers available models (scans the CLI's configuration file). Session resumption works. **Suitable for tinkerers who want to customize their model catalog.** @@ -72,7 +77,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu | Status | Tools | Meaning | |---|---|---| -| ✅ Really works | Claude Code, Copilot, Hermes, Kimi, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context | +| ✅ Really works | Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context | | ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** | | ❌ None | Gemini | The CLI has no resume mechanism | @@ -80,7 +85,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu ## MCP configuration: only Claude Code actually reads it -**Of the 10 tools, only Claude Code actually consumes `mcp_config`**. The other 9 accept the field but **completely ignore it** — no error, no warning, the config just has no effect. +**Of the 11 tools, only Claude Code actually consumes `mcp_config`**. The other 10 accept the field but **completely ignore it** — no error, no warning, the config just has no effect. If you set `mcp_config` in an agent configuration but pick a tool other than Claude Code, your MCP servers have **no effect** on that agent. MCP integration currently covers Claude Code only. @@ -97,6 +102,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica | Copilot | `.github/skills/` | ✅ Native | | Cursor | `.cursor/skills/` | ✅ Native | | Kimi | `.kimi/skills/` | ✅ Native | +| Kiro CLI | `.kiro/skills/` | ✅ Native | | OpenCode | `.config/opencode/skills/` | ✅ Native | | Pi | `.pi/skills/` | ✅ Native | | Gemini | `.agent_context/skills/` | ⚠️ Generic fallback | diff --git a/apps/docs/content/docs/providers.zh.mdx b/apps/docs/content/docs/providers.zh.mdx index d230f2c7ea..98b5105895 100644 --- a/apps/docs/content/docs/providers.zh.mdx +++ b/apps/docs/content/docs/providers.zh.mdx @@ -1,11 +1,11 @@ --- title: AI 编程工具对照 -description: Multica 支持 10 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。 +description: Multica 支持 11 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。 --- import { Callout } from "fumadocs-ui/components/callout"; -Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。 +Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。 创建智能体时挑选工具的指引见 [创建和配置智能体](/agents-create)。 @@ -20,6 +20,7 @@ Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接 | **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静态 | | **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback)| 动态发现 | | **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 | +| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 动态发现 | | **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | 动态发现 | | **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 | | **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 | @@ -28,7 +29,7 @@ Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接 ### Claude Code -Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **10 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。 +Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **11 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。 ### Codex @@ -54,6 +55,10 @@ Nous Research 出品。使用 ACP 协议(和 Kimi 共享传输层)。会话 Moonshot 出品,中国市场向。和 Hermes 共享 ACP 协议,但 skill 路径 `.kimi/skills/` 是 Kimi CLI 的原生发现机制——和 Hermes 的 fallback 不一样。 +### Kiro CLI + +Amazon 出品。通过 `kiro-cli acp` 使用 ACP stdio 协议。会话恢复走 ACP `session/load`,模型选择走 `session/set_model`,skill 会复制到 `.kiro/skills/` 让 Kiro 做项目级原生发现。 + ### OpenCode SST 出品,开源。动态发现可用模型(扫 CLI 的配置文件)。会话恢复真用。**适合爱折腾、想自定义模型目录**的开发者。 @@ -72,7 +77,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session | 状态 | 工具 | 含义 | |---|---|---| -| ✅ 真用 | Claude Code、Copilot、Hermes、Kimi、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 | +| ✅ 真用 | Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 | | ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到(Codex 静默回落、Cursor session id 不回传)—— **当作不支持** | | ❌ 无 | Gemini | CLI 无 resume 机制 | @@ -80,7 +85,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session ## MCP 配置:只有 Claude Code 真的读 -**10 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 9 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。 +**11 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 10 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。 如果你在智能体配置里设置了 `mcp_config`,但选了 Claude Code 之外的工具,你的 MCP server 对这个智能体**没有效果**。目前的 MCP 集成只覆盖 Claude Code。 @@ -97,6 +102,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session | Copilot | `.github/skills/` | ✅ 原生 | | Cursor | `.cursor/skills/` | ✅ 原生 | | Kimi | `.kimi/skills/` | ✅ 原生 | +| Kiro CLI | `.kiro/skills/` | ✅ 原生 | | OpenCode | `.config/opencode/skills/` | ✅ 原生 | | Pi | `.pi/skills/` | ✅ 原生 | | Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback | diff --git a/apps/docs/content/docs/skills.mdx b/apps/docs/content/docs/skills.mdx index 5d799d6cf3..1971a56e9e 100644 --- a/apps/docs/content/docs/skills.mdx +++ b/apps/docs/content/docs/skills.mdx @@ -64,4 +64,4 @@ By now you know what an agent is, how to create one, and how to attach skills. T - [Daemon and runtimes](/daemon-runtimes) — where agents actually run, and how to tell online from offline - [Executing tasks](/tasks) — the full lifecycle of one "agent work session" -- [AI coding tools comparison](/providers) — full comparison of all 10 tools (including each one's skill injection path) +- [AI coding tools comparison](/providers) — full comparison of all 11 tools (including each one's skill injection path) diff --git a/apps/docs/content/docs/skills.zh.mdx b/apps/docs/content/docs/skills.zh.mdx index ef516b85f1..47578371ab 100644 --- a/apps/docs/content/docs/skills.zh.mdx +++ b/apps/docs/content/docs/skills.zh.mdx @@ -64,4 +64,4 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能 - [守护进程与运行时](/daemon-runtimes) —— 智能体到底跑在哪、怎么判断在线 / 离线 - [执行任务](/tasks) —— 一次"智能体工作"的完整生命周期 -- [AI 编程工具对照](/providers) —— 10 款工具的完整对比(含每款的 Skill 注入路径) +- [AI 编程工具对照](/providers) —— 11 款工具的完整对比(含每款的 Skill 注入路径) diff --git a/apps/docs/content/docs/tasks.mdx b/apps/docs/content/docs/tasks.mdx index b10596ffc2..5952e80f8a 100644 --- a/apps/docs/content/docs/tasks.mdx +++ b/apps/docs/content/docs/tasks.mdx @@ -100,7 +100,7 @@ Multica pins the session ID **twice** during a task: once at the start (when the But **which AI coding tools actually support this** varies a lot: -- ✅ **Real support** — Claude Code, Copilot, Hermes, Kimi, OpenCode, OpenClaw, Pi +- ✅ **Real support** — Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi - ⚠️ **Code exists but unusable** — Codex, Cursor - ❌ **No support** — Gemini @@ -108,5 +108,5 @@ See [Providers Matrix → Session resumption](/providers#session-resumption-who- ## Next -- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools (including the exact session-resumption status) +- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools (including the exact session-resumption status) - [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task diff --git a/apps/docs/content/docs/tasks.zh.mdx b/apps/docs/content/docs/tasks.zh.mdx index 916716d8ce..f682ef5e0f 100644 --- a/apps/docs/content/docs/tasks.zh.mdx +++ b/apps/docs/content/docs/tasks.zh.mdx @@ -100,7 +100,7 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI 但**哪些 AI 编程工具真的支持**差别很大: -- ✅ **真支持**——Claude Code、Copilot、Hermes、Kimi、OpenCode、OpenClaw、Pi +- ✅ **真支持**——Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi - ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor - ❌ **不支持**——Gemini @@ -108,5 +108,5 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI ## 下一步 -- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态) +- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态) - [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Autopilots](/autopilots) —— 触发执行任务的四种方式 diff --git a/docs/product-overview.md b/docs/product-overview.md index 2d9b9c2324..8c85f0daff 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -82,7 +82,7 @@ Multica 做的事: Multica **不自己训模型**,也不锁定某一家厂商。它是调度器,本地 daemon 会自动探测以下 CLI 工具并接入: -Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent +Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent · Kimi · Kiro CLI 每个 agent 可以配置自己的模型、API Key、环境变量、MCP 服务器。 @@ -244,7 +244,7 @@ Project 相比 Issue 是更高层的组织单元。一个 issue 可以不属于 #### 配置字段 - **基本信息**:名字、描述、头像(自动生成) -- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor 中的哪一个 +- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor / Kimi / Kiro 中的哪一个 - **Runtime**:绑定到哪个运行时(即在哪台机器上跑) - **Instructions 说明书**:agent 的系统提示词("你是一个资深工程师...") - **Custom Env**:要注入到 CLI 进程的环境变量(如 `ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL`、`CLAUDE_CODE_USE_BEDROCK`) @@ -291,7 +291,7 @@ Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent `multica` CLI 在用户的机器上启动一个后台进程(macOS launchd / Linux systemd / Windows 服务风格),它: -1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`) +1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) 2. 向 server **注册** 为一组 runtime(一个 CLI = 一个 runtime) 3. 每 3 秒 **轮询** 一次 server,有任务就认领 4. 每 15 秒 **心跳**(keepalive),报告自己还活着 diff --git a/packages/views/onboarding/steps/step-welcome.tsx b/packages/views/onboarding/steps/step-welcome.tsx index 6df3dd1020..d47013c55b 100644 --- a/packages/views/onboarding/steps/step-welcome.tsx +++ b/packages/views/onboarding/steps/step-welcome.tsx @@ -265,6 +265,8 @@ type ProviderName = | "opencode" | "openclaw" | "hermes" + | "kimi" + | "kiro" | "pi" | "copilot" | "cursor"; diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx index ab449bd2bc..364283b284 100644 --- a/packages/views/runtimes/components/provider-logo.tsx +++ b/packages/views/runtimes/components/provider-logo.tsx @@ -125,6 +125,17 @@ function KimiLogo({ className }: { className: string }) { ); } +// Kiro CLI — compact "K" mark for runtime rows. +function KiroLogo({ className }: { className: string }) { + return ( + + + + + + ); +} + export function ProviderLogo({ provider, className = "h-4 w-4", @@ -151,6 +162,8 @@ export function ProviderLogo({ return ; case "kimi": return ; + case "kiro": + return ; default: return ; } diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 10c306a606..626c2874f0 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -35,7 +35,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, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro 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) @@ -152,8 +152,15 @@ func LoadConfig(overrides Overrides) (Config, error) { Model: strings.TrimSpace(os.Getenv("MULTICA_KIMI_MODEL")), } } + kiroPath := envOrDefault("MULTICA_KIRO_PATH", "kiro-cli") + if _, err := exec.LookPath(kiroPath); err == nil { + agents["kiro"] = AgentEntry{ + Path: kiroPath, + Model: strings.TrimSpace(os.Getenv("MULTICA_KIRO_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, cursor-agent, kimi, or kiro-cli and ensure it is on PATH") } // Host info diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go index be481ceefc..cf397b4245 100644 --- a/server/internal/daemon/execenv/context.go +++ b/server/internal/daemon/execenv/context.go @@ -18,6 +18,7 @@ import ( // Pi: skills → {workDir}/.pi/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) +// Kiro: skills → {workDir}/.kiro/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") @@ -74,6 +75,10 @@ func resolveSkillsDir(workDir, provider string) (string, error) { // 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") + case "kiro": + // Kiro CLI auto-discovers project-level skills from .kiro/skills/ + // in the workdir. + skillsDir = filepath.Join(workDir, ".kiro", "skills") default: // Fallback: write to .agent_context/skills/ (referenced by meta config). skillsDir = filepath.Join(workDir, ".agent_context", "skills") diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index c05e96d0c4..a69877a580 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -24,7 +24,7 @@ type PrepareParams struct { WorkspaceID string // workspace UUID — tasks are grouped under this TaskID string // task UUID — used for directory name AgentName string // for git branch naming only - Provider string // agent provider ("claude", "codex") — determines skill injection paths + Provider string // agent provider (determines runtime config and skill injection paths) CodexVersion string // detected Codex CLI version (only used when Provider == "codex") Task TaskContextForEnv // context data for writing files } diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index 9c56d80ce5..515a73a27a 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -619,6 +619,33 @@ func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) { } } +func TestWriteContextFilesKiroNativeSkills(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + ctx := TaskContextForEnv{ + IssueID: "kiro-skill-test", + AgentSkills: []SkillContextForEnv{ + {Name: "Go Conventions", Content: "Follow Go conventions."}, + }, + } + + if err := writeContextFiles(dir, "kiro", ctx); err != nil { + t.Fatalf("writeContextFiles failed: %v", err) + } + + skillMd, err := os.ReadFile(filepath.Join(dir, ".kiro", "skills", "go-conventions", "SKILL.md")) + if err != nil { + t.Fatalf("failed to read .kiro/skills/go-conventions/SKILL.md: %v", err) + } + if !strings.Contains(string(skillMd), "Follow Go conventions.") { + t.Error("SKILL.md missing content") + } + if _, err := os.Stat(filepath.Join(dir, ".agent_context", "skills")); !os.IsNotExist(err) { + t.Error("expected .agent_context/skills/ to NOT exist for Kiro provider") + } +} + func TestInjectRuntimeConfigOpencode(t *testing.T) { t.Parallel() dir := t.TempDir() @@ -655,6 +682,36 @@ func TestInjectRuntimeConfigOpencode(t *testing.T) { } } +func TestInjectRuntimeConfigKiro(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + ctx := TaskContextForEnv{ + IssueID: "test-issue-id", + AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}}, + } + + if err := InjectRuntimeConfig(dir, "kiro", ctx); err != nil { + t.Fatalf("InjectRuntimeConfig failed: %v", err) + } + + content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + if err != nil { + t.Fatalf("failed to read AGENTS.md: %v", err) + } + + s := string(content) + if !strings.Contains(s, "Multica Agent Runtime") { + t.Error("AGENTS.md missing meta skill header") + } + if !strings.Contains(s, "Coding") { + t.Error("AGENTS.md missing skill name") + } + if !strings.Contains(s, "discovered automatically") { + t.Error("AGENTS.md missing native skill discovery hint") + } +} + func TestPrepareWithRepoContextOpencode(t *testing.T) { t.Parallel() workspacesRoot := t.TempDir() diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 57c2422e4b..38785dbfa3 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -20,13 +20,14 @@ import ( // For Pi: writes {workDir}/AGENTS.md (skills discovered natively from .pi/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) +// For Kiro: writes {workDir}/AGENTS.md (Kiro 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", "hermes", "pi", "cursor", "kimi": + case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro": return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644) case "gemini": return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644) @@ -191,8 +192,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", "kimi", "kiro": + // Codex, Copilot, OpenCode, OpenClaw, Pi, Cursor, Kimi, and Kiro discover skills natively from their respective paths — just list names. b.WriteString("You have the following skills installed (discovered automatically):\n\n") case "gemini", "hermes": // Gemini reads GEMINI.md directly; Hermes has no native skills discovery path diff --git a/server/internal/daemon/local_skills.go b/server/internal/daemon/local_skills.go index f51ee65010..ffb61f4b57 100644 --- a/server/internal/daemon/local_skills.go +++ b/server/internal/daemon/local_skills.go @@ -46,6 +46,7 @@ type runtimeLocalSkillBundle struct { // - Pi: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md // - Cursor: official forum guidance referencing the built-in /create-skill flow // (https://forum.cursor.com/t/cursor-doesnt-know-new-skills-arens-saved/158507) +// - Kiro: project and user-level .kiro/skills directories discovered by Kiro CLI // // Longer-term this mapping would be better colocated with the provider // definitions under server/pkg/agent so adding a new runtime can't silently @@ -75,6 +76,8 @@ func localSkillRootForProvider(provider string) (string, bool, error) { return filepath.Join(home, ".pi", "agent", "skills"), true, nil case "cursor": return filepath.Join(home, ".cursor", "skills"), true, nil + case "kiro": + return filepath.Join(home, ".kiro", "skills"), true, nil default: return "", false, nil } diff --git a/server/internal/daemon/local_skills_test.go b/server/internal/daemon/local_skills_test.go index 96988f1172..6de9532f85 100644 --- a/server/internal/daemon/local_skills_test.go +++ b/server/internal/daemon/local_skills_test.go @@ -70,6 +70,35 @@ func TestListRuntimeLocalSkills_Claude(t *testing.T) { } } +func TestListRuntimeLocalSkills_Kiro(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + writeTestLocalSkill(t, filepath.Join(home, ".kiro", "skills"), "review-helper", map[string]string{ + "SKILL.md": "---\nname: Kiro Review\ndescription: Review code with Kiro\n---\n# Kiro Review\n", + }) + + skills, supported, err := listRuntimeLocalSkills("kiro") + if err != nil { + t.Fatalf("listRuntimeLocalSkills: %v", err) + } + if !supported { + t.Fatal("kiro should be supported") + } + if len(skills) != 1 { + t.Fatalf("expected 1 skill, got %d", len(skills)) + } + if skills[0].Key != "review-helper" { + t.Fatalf("key = %q, want review-helper", skills[0].Key) + } + if skills[0].Name != "Kiro Review" { + t.Fatalf("name = %q, want Kiro Review", skills[0].Name) + } + if skills[0].SourcePath != "~/.kiro/skills/review-helper" { + t.Fatalf("source_path = %q", skills[0].SourcePath) + } +} + // Skill installers (for example lark-cli) place every skill at a shared // location like ~/.agents/skills/ and symlink each one into the // runtime root (~/.claude/skills/). The previous filepath.WalkDir @@ -180,8 +209,8 @@ func TestListRuntimeLocalSkills_DescendsIntoNestedSkillDirs(t *testing.T) { // Top-level skill — should register at key="top" and its child SKILL.md // must NOT register as a separate skill. writeTestLocalSkill(t, root, "top", map[string]string{ - "SKILL.md": "---\nname: Top\n---\n", - "templates/SKILL.md": "not a real skill — sub-template that happens to share the filename", + "SKILL.md": "---\nname: Top\n---\n", + "templates/SKILL.md": "not a real skill — sub-template that happens to share the filename", }) // Nested skill — only valid SKILL.md is at depth 2. diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index 4c4256c120..246fe14943 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -1,6 +1,6 @@ // Package agent provides a unified interface for executing prompts via // coding agents (Claude Code, Codex, Copilot, OpenCode, OpenClaw, Hermes, -// Gemini, Pi, Cursor, Kimi). It mirrors the happy-cli AgentBackend +// Gemini, Pi, Cursor, Kimi, Kiro). It mirrors the happy-cli AgentBackend // pattern, translated to idiomatic Go. package agent @@ -88,13 +88,13 @@ type Result struct { // Config configures a Backend instance. type Config struct { - ExecutablePath string // path to CLI binary (claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi) + ExecutablePath string // path to CLI binary (claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro-cli) Env map[string]string // extra environment variables Logger *slog.Logger } // New creates a Backend for the given agent type. -// Supported types: "claude", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi". +// Supported types: "claude", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi", "kiro". func New(agentType string, cfg Config) (Backend, error) { if cfg.Logger == nil { cfg.Logger = slog.Default() @@ -121,8 +121,10 @@ func New(agentType string, cfg Config) (Backend, error) { return &cursorBackend{cfg: cfg}, nil case "kimi": return &kimiBackend{cfg: cfg}, nil + case "kiro": + return &kiroBackend{cfg: cfg}, nil default: - return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi)", agentType) + return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro)", agentType) } } @@ -148,6 +150,7 @@ var launchHeaders = map[string]string{ "opencode": "opencode run (json)", "pi": "pi (json mode)", "kimi": "kimi acp", + "kiro": "kiro-cli acp", } // LaunchHeader returns the user-visible launch skeleton for agentType, or an diff --git a/server/pkg/agent/agent_test.go b/server/pkg/agent/agent_test.go index ce28fa1eee..8324b35a7f 100644 --- a/server/pkg/agent/agent_test.go +++ b/server/pkg/agent/agent_test.go @@ -72,7 +72,7 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) { // entry to launchHeaders in agent.go and extend this list. supported := []string{ "claude", "codex", "copilot", "cursor", "gemini", - "hermes", "kimi", "openclaw", "opencode", "pi", + "hermes", "kimi", "kiro", "openclaw", "opencode", "pi", } for _, t_ := range supported { if header := LaunchHeader(t_); header == "" { diff --git a/server/pkg/agent/hermes.go b/server/pkg/agent/hermes.go index cc60cf55c5..c4c9193e42 100644 --- a/server/pkg/agent/hermes.go +++ b/server/pkg/agent/hermes.go @@ -587,7 +587,7 @@ func (c *hermesClient) handleNotification(raw map[string]json.RawMessage) { var method string _ = json.Unmarshal(raw["method"], &method) - if method != "session/update" { + if method != "session/update" && method != "session/notification" { return } @@ -602,23 +602,66 @@ func (c *hermesClient) handleNotification(raw map[string]json.RawMessage) { return } - // Parse the update discriminator. + updateType, updateData := normalizeACPUpdate(params.Update) + + switch updateType { + case "agent_message_chunk": + c.handleAgentMessage(updateData) + case "agent_thought_chunk": + c.handleAgentThought(updateData) + case "tool_call": + c.handleToolCallStart(updateData) + case "tool_call_update": + c.handleToolCallUpdate(updateData) + case "usage_update": + c.handleUsageUpdate(updateData) + case "turn_end": + c.extractPromptResult(updateData) + } +} + +func normalizeACPUpdate(data json.RawMessage) (string, json.RawMessage) { var updateType struct { SessionUpdate string `json:"sessionUpdate"` + Type string `json:"type"` + } + _ = json.Unmarshal(data, &updateType) + if updateType.SessionUpdate != "" { + return normalizeACPUpdateType(updateType.SessionUpdate), data + } + if updateType.Type != "" { + return normalizeACPUpdateType(updateType.Type), data } - _ = json.Unmarshal(params.Update, &updateType) - switch updateType.SessionUpdate { - case "agent_message_chunk": - c.handleAgentMessage(params.Update) - case "agent_thought_chunk": - c.handleAgentThought(params.Update) - case "tool_call": - c.handleToolCallStart(params.Update) - case "tool_call_update": - c.handleToolCallUpdate(params.Update) - case "usage_update": - c.handleUsageUpdate(params.Update) + // Some ACP implementations serialize enum variants as an externally + // tagged object: {"agentMessageChunk": {"content": ...}}. + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(data, &wrapper); err == nil && len(wrapper) == 1 { + for k, v := range wrapper { + return normalizeACPUpdateType(k), v + } + } + + return "", data +} + +func normalizeACPUpdateType(t string) string { + key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(t), "_", ""), "-", "")) + switch key { + case "agentmessagechunk": + return "agent_message_chunk" + case "agentthoughtchunk": + return "agent_thought_chunk" + case "toolcall": + return "tool_call" + case "toolcallupdate": + return "tool_call_update" + case "usageupdate": + return "usage_update" + case "turnend", "endturn": + return "turn_end" + default: + return "" } } @@ -655,9 +698,12 @@ func (c *hermesClient) handleAgentThought(data json.RawMessage) { func (c *hermesClient) handleToolCallStart(data json.RawMessage) { var msg struct { ToolCallID string `json:"toolCallId"` + Name string `json:"name"` Title string `json:"title"` Kind string `json:"kind"` RawInput map[string]any `json:"rawInput"` + Input map[string]any `json:"input"` + Parameters map[string]any `json:"parameters"` Content []json.RawMessage `json:"content"` } if err := json.Unmarshal(data, &msg); err != nil { @@ -665,15 +711,25 @@ func (c *hermesClient) handleToolCallStart(data json.RawMessage) { } toolName := hermesToolNameFromTitle(msg.Title, msg.Kind) + if toolName == "" { + toolName = msg.Name + } + rawInput := msg.RawInput + if rawInput == nil { + rawInput = msg.Input + } + if rawInput == nil { + rawInput = msg.Parameters + } // Hermes pre-populates rawInput on the initial tool_call — emit // MessageToolUse immediately so the UI can show the tool invocation // live. Record the emission so handleToolCallUpdate doesn't re-emit // on completion. - if msg.RawInput != nil { + if rawInput != nil { c.trackTool(msg.ToolCallID, &pendingToolCall{ toolName: toolName, - input: msg.RawInput, + input: rawInput, emitted: true, }) if c.onMessage != nil { @@ -681,7 +737,7 @@ func (c *hermesClient) handleToolCallStart(data json.RawMessage) { Type: MessageToolUse, Tool: toolName, CallID: msg.ToolCallID, - Input: msg.RawInput, + Input: rawInput, }) } return @@ -702,16 +758,32 @@ func (c *hermesClient) handleToolCallUpdate(data json.RawMessage) { var msg struct { ToolCallID string `json:"toolCallId"` Status string `json:"status"` + Name string `json:"name"` Title string `json:"title"` Kind string `json:"kind"` RawInput map[string]any `json:"rawInput"` + Input map[string]any `json:"input"` + Parameters map[string]any `json:"parameters"` RawOutput string `json:"rawOutput"` + Output string `json:"output"` Content []json.RawMessage `json:"content"` } if err := json.Unmarshal(data, &msg); err != nil { return } + rawInput := msg.RawInput + if rawInput == nil { + rawInput = msg.Input + } + if rawInput == nil { + rawInput = msg.Parameters + } + title := msg.Title + if title == "" { + title = msg.Name + } + // Mid-stream: only buffer updates. Kimi emits many of these per // tool call, each carrying the cumulative args JSON so far. if msg.Status != "completed" && msg.Status != "failed" { @@ -727,9 +799,12 @@ func (c *hermesClient) handleToolCallUpdate(data json.RawMessage) { // Completion: emit any deferred MessageToolUse first, then the result. pending := c.takePendingTool(msg.ToolCallID) - c.emitDeferredToolUse(pending, msg.ToolCallID, msg.Title, msg.Kind, msg.RawInput) + c.emitDeferredToolUse(pending, msg.ToolCallID, title, msg.Kind, rawInput) output := msg.RawOutput + if output == "" { + output = msg.Output + } if output == "" { output = extractACPToolCallText(msg.Content) } @@ -961,7 +1036,7 @@ func (c *hermesClient) handleUsageUpdate(data json.RawMessage) { // ── Helpers ── // extractACPSessionID pulls `sessionId` out of a session/new or -// session/resume response. Shared by all ACP backends (hermes, kimi, +// session/resume response. Shared by all ACP backends (hermes, kimi, kiro, // and anything else that follows the standard ACP schema). func extractACPSessionID(result json.RawMessage) string { var r struct { diff --git a/server/pkg/agent/hermes_test.go b/server/pkg/agent/hermes_test.go index 852b63b5f6..5476fd19ee 100644 --- a/server/pkg/agent/hermes_test.go +++ b/server/pkg/agent/hermes_test.go @@ -292,6 +292,28 @@ func TestHermesClientHandleAgentMessage(t *testing.T) { } } +func TestHermesClientHandleSessionNotificationAgentMessage(t *testing.T) { + t.Parallel() + + var got Message + c := &hermesClient{ + pending: make(map[int]*pendingRPC), + onMessage: func(msg Message) { + got = msg + }, + } + + line := `{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_1","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"Hello from Kiro"}}}}` + c.handleLine(line) + + if got.Type != MessageText { + t.Errorf("type: got %v, want MessageText", got.Type) + } + if got.Content != "Hello from Kiro" { + t.Errorf("content: got %q, want %q", got.Content, "Hello from Kiro") + } +} + func TestHermesClientHandleAgentThought(t *testing.T) { t.Parallel() @@ -342,6 +364,62 @@ func TestHermesClientHandleToolCallStart(t *testing.T) { } } +func TestHermesClientHandleSessionNotificationToolCall(t *testing.T) { + t.Parallel() + + var got []Message + c := &hermesClient{ + pending: make(map[int]*pendingRPC), + onMessage: func(msg Message) { + got = append(got, msg) + }, + } + + c.handleLine(`{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_1","update":{"type":"ToolCall","toolCallId":"tc-kiro","name":"Shell","status":"pending","parameters":{"command":"pwd"}}}}`) + c.handleLine(`{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_1","update":{"type":"ToolCallUpdate","toolCallId":"tc-kiro","status":"completed","name":"Shell","output":"/tmp/project\n"}}}`) + + if len(got) != 2 { + t.Fatalf("expected [ToolUse, ToolResult], got %+v", got) + } + if got[0].Type != MessageToolUse { + t.Errorf("first message: got %v, want MessageToolUse", got[0].Type) + } + if got[0].Tool != "Shell" { + t.Errorf("first tool: got %q, want Shell", got[0].Tool) + } + if cmd, _ := got[0].Input["command"].(string); cmd != "pwd" { + t.Errorf("first input.command: got %v, want pwd", got[0].Input["command"]) + } + if got[1].Type != MessageToolResult { + t.Errorf("second message: got %v, want MessageToolResult", got[1].Type) + } + if got[1].Output != "/tmp/project\n" { + t.Errorf("second output: got %q", got[1].Output) + } +} + +func TestHermesClientHandleSessionNotificationTurnEnd(t *testing.T) { + t.Parallel() + + var got hermesPromptResult + c := &hermesClient{ + pending: make(map[int]*pendingRPC), + onPromptDone: func(result hermesPromptResult) { + got = result + }, + } + + line := `{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_1","update":{"type":"TurnEnd","stopReason":"end_turn","usage":{"inputTokens":3,"outputTokens":4,"cachedReadTokens":1}}}}` + c.handleLine(line) + + if got.stopReason != "end_turn" { + t.Errorf("stopReason: got %q, want end_turn", got.stopReason) + } + if got.usage.InputTokens != 3 || got.usage.OutputTokens != 4 || got.usage.CacheReadTokens != 1 { + t.Errorf("usage: got %+v", got.usage) + } +} + func TestHermesClientHandleToolCallComplete(t *testing.T) { t.Parallel() diff --git a/server/pkg/agent/kiro.go b/server/pkg/agent/kiro.go new file mode 100644 index 0000000000..f279f50645 --- /dev/null +++ b/server/pkg/agent/kiro.go @@ -0,0 +1,340 @@ +package agent + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "time" +) + +// kiroBlockedArgs are flags hardcoded by the daemon that must not be +// overridden by user-configured custom_args. `acp` is the protocol subcommand, +// and --trust-all-tools covers Kiro's CLI-level tool gate while +// hermesClient handles ACP session/request_permission auto-approval. In Kiro +// CLI 2.1.1, `-a` is short for --trust-all-tools, not --agent; --agent remains +// allowed so users can select a custom Kiro agent. +var kiroBlockedArgs = map[string]blockedArgMode{ + "acp": blockedStandalone, + "-a": blockedStandalone, + "--trust-all-tools": blockedStandalone, + "--trust-tools": blockedWithValue, +} + +// kiroBackend implements Backend by spawning `kiro-cli acp` and communicating +// via the standard ACP JSON-RPC 2.0 transport over stdin/stdout. +// +// Kiro CLI advertises loadSession, returns models from session/new, and supports +// session/set_model, so the existing Hermes/Kimi ACP client can drive it with +// only provider-specific launch and tool-name normalization. +type kiroBackend struct { + cfg Config +} + +func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) { + execPath := b.cfg.ExecutablePath + if execPath == "" { + execPath = "kiro-cli" + } + if _, err := exec.LookPath(execPath); err != nil { + return nil, fmt.Errorf("kiro executable not found at %q: %w", execPath, err) + } + + timeout := opts.Timeout + if timeout == 0 { + timeout = 20 * time.Minute + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + + kiroArgs := append([]string{"acp", "--trust-all-tools"}, filterCustomArgs(opts.CustomArgs, kiroBlockedArgs, b.cfg.Logger)...) + cmd := exec.CommandContext(runCtx, execPath, kiroArgs...) + hideAgentWindow(cmd) + b.cfg.Logger.Info("agent command", "exec", execPath, "args", kiroArgs) + if opts.Cwd != "" { + cmd.Dir = opts.Cwd + } + cmd.Env = buildEnv(b.cfg.Env) + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("kiro stdout pipe: %w", err) + } + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("kiro stdin pipe: %w", err) + } + providerErr := newACPProviderErrorSniffer("kiro") + cmd.Stderr = io.MultiWriter(newLogWriter(b.cfg.Logger, "[kiro:stderr] "), providerErr) + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start kiro: %w", err) + } + + b.cfg.Logger.Info("kiro acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd) + + msgCh := make(chan Message, 256) + resCh := make(chan Result, 1) + + var outputMu sync.Mutex + var output strings.Builder + + promptDone := make(chan hermesPromptResult, 1) + + c := &hermesClient{ + cfg: b.cfg, + stdin: stdin, + pending: make(map[int]*pendingRPC), + pendingTools: make(map[string]*pendingToolCall), + onMessage: func(msg Message) { + if msg.Type == MessageToolUse { + msg.Tool = kiroToolNameFromTitle(msg.Tool) + } + if msg.Type == MessageText { + outputMu.Lock() + output.WriteString(msg.Content) + outputMu.Unlock() + } + trySend(msgCh, msg) + }, + onPromptDone: func(result hermesPromptResult) { + select { + case promptDone <- result: + default: + } + }, + } + + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + c.handleLine(line) + } + c.closeAllPending(fmt.Errorf("kiro process exited")) + }() + + go func() { + defer cancel() + defer close(msgCh) + defer close(resCh) + defer func() { + stdin.Close() + _ = cmd.Wait() + }() + + startTime := time.Now() + finalStatus := "completed" + var finalError string + var sessionID string + + _, err := c.request(runCtx, "initialize", map[string]any{ + "protocolVersion": 1, + "clientInfo": map[string]any{ + "name": "multica-agent-sdk", + "version": "0.2.0", + }, + "clientCapabilities": map[string]any{}, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("kiro initialize failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + + cwd := opts.Cwd + if cwd == "" { + cwd = "." + } + + if opts.ResumeSessionID != "" { + _, err := c.request(runCtx, "session/load", map[string]any{ + "cwd": cwd, + "sessionId": opts.ResumeSessionID, + "mcpServers": []any{}, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("kiro session/load failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + sessionID = opts.ResumeSessionID + } else { + result, err := c.request(runCtx, "session/new", map[string]any{ + "cwd": cwd, + "mcpServers": []any{}, + }) + if err != nil { + finalStatus = "failed" + finalError = fmt.Sprintf("kiro session/new failed: %v", err) + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + sessionID = extractACPSessionID(result) + if sessionID == "" { + finalStatus = "failed" + finalError = "kiro session/new returned no session ID" + resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()} + return + } + } + + c.sessionID = sessionID + b.cfg.Logger.Info("kiro session created", "session_id", sessionID) + + if opts.Model != "" { + if _, err := c.request(runCtx, "session/set_model", map[string]any{ + "sessionId": sessionID, + "modelId": opts.Model, + }); err != nil { + b.cfg.Logger.Warn("kiro set_session_model failed", "error", err, "requested_model", opts.Model) + finalStatus = "failed" + finalError = fmt.Sprintf("kiro could not switch to model %q: %v", opts.Model, err) + resCh <- Result{ + Status: finalStatus, + Error: finalError, + DurationMs: time.Since(startTime).Milliseconds(), + SessionID: sessionID, + } + return + } + b.cfg.Logger.Info("kiro session model set", "model", opts.Model) + } + + userText := prompt + if opts.SystemPrompt != "" { + userText = opts.SystemPrompt + "\n\n---\n\n" + prompt + } + + promptBlocks := []map[string]any{ + {"type": "text", "text": userText}, + } + // Kiro's published docs use `content`, while Kiro CLI 2.1.1 still + // requires the standard ACP `prompt` field. Send both so either wire + // shape can drive the turn. + // TODO: drop one field once Kiro lands on a single canonical payload. + _, err = c.request(runCtx, "session/prompt", map[string]any{ + "sessionId": sessionID, + "content": promptBlocks, + "prompt": promptBlocks, + }) + if err != nil { + if runCtx.Err() == context.DeadlineExceeded { + finalStatus = "timeout" + finalError = fmt.Sprintf("kiro timed out after %s", timeout) + } else if runCtx.Err() == context.Canceled { + finalStatus = "aborted" + finalError = "execution cancelled" + } else { + finalStatus = "failed" + finalError = fmt.Sprintf("kiro session/prompt failed: %v", err) + } + } else { + select { + case pr := <-promptDone: + if pr.stopReason == "cancelled" { + finalStatus = "aborted" + finalError = "kiro cancelled the prompt" + } + c.usageMu.Lock() + c.usage.InputTokens += pr.usage.InputTokens + c.usage.OutputTokens += pr.usage.OutputTokens + c.usageMu.Unlock() + default: + } + } + + duration := time.Since(startTime) + b.cfg.Logger.Info("kiro finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String()) + + stdin.Close() + cancel() + + <-readerDone + + outputMu.Lock() + finalOutput := output.String() + outputMu.Unlock() + + if finalStatus == "completed" && finalOutput == "" { + if msg := providerErr.message(); msg != "" { + finalStatus = "failed" + finalError = msg + } + } + + c.usageMu.Lock() + u := c.usage + c.usageMu.Unlock() + + var usageMap map[string]TokenUsage + if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 { + model := opts.Model + if model == "" { + model = "unknown" + } + usageMap = map[string]TokenUsage{model: u} + } + + resCh <- Result{ + Status: finalStatus, + Output: finalOutput, + Error: finalError, + DurationMs: duration.Milliseconds(), + SessionID: sessionID, + Usage: usageMap, + } + }() + + return &Session{Messages: msgCh, Result: resCh}, nil +} + +func kiroToolNameFromTitle(title string) string { + t := strings.TrimSpace(title) + if t == "" { + return "" + } + + if idx := strings.Index(t, ":"); idx > 0 { + t = strings.TrimSpace(t[:idx]) + } + + lower := strings.ToLower(t) + switch lower { + case "read", "read file": + return "read_file" + case "write", "write file": + return "write_file" + case "edit", "patch": + return "edit_file" + case "shell", "bash", "terminal", "run command", "run shell command": + return "terminal" + case "grep", "search", "find": + return "search_files" + case "glob": + return "glob" + case "code": + return "code" + case "web search": + return "web_search" + case "fetch", "web fetch": + return "web_fetch" + case "todo", "todo write", "todo list", "todo_list": + return "todo_write" + } + + return strings.ReplaceAll(lower, " ", "_") +} diff --git a/server/pkg/agent/kiro_test.go b/server/pkg/agent/kiro_test.go new file mode 100644 index 0000000000..acf7915f44 --- /dev/null +++ b/server/pkg/agent/kiro_test.go @@ -0,0 +1,284 @@ +package agent + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNewReturnsKiroBackend(t *testing.T) { + t.Parallel() + b, err := New("kiro", Config{ExecutablePath: "/nonexistent/kiro-cli"}) + if err != nil { + t.Fatalf("New(kiro) error: %v", err) + } + if _, ok := b.(*kiroBackend); !ok { + t.Fatalf("expected *kiroBackend, got %T", b) + } +} + +func TestKiroToolNameFromTitle(t *testing.T) { + t.Parallel() + tests := []struct { + title string + want string + }{ + {"Read file: /tmp/foo.go", "read_file"}, + {"Write: /tmp/bar.go", "write_file"}, + {"Patch: /tmp/x", "edit_file"}, + {"Shell: ls -la", "terminal"}, + {"Run command: pwd", "terminal"}, + {"grep", "search_files"}, + {"Glob: *.go", "glob"}, + {"Code", "code"}, + {"Todo List", "todo_write"}, + {"Custom Thing", "custom_thing"}, + {"", ""}, + } + for _, tt := range tests { + got := kiroToolNameFromTitle(tt.title) + if got != tt.want { + t.Errorf("kiroToolNameFromTitle(%q) = %q, want %q", tt.title, got, tt.want) + } + } +} + +func fakeKiroACPScript() string { + return `#!/bin/sh +if [ -n "$KIRO_ARGS_FILE" ]; then + for arg in "$@"; do + printf '%s\n' "$arg" >> "$KIRO_ARGS_FILE" + done +fi +while IFS= read -r line; do + if [ -n "$KIRO_REQUESTS_FILE" ]; then + printf '%s\n' "$line" >> "$KIRO_REQUESTS_FILE" + fi + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true}}}\n' "$id" + ;; + *'"method":"session/new"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_new","models":{"currentModelId":"auto","availableModels":[{"modelId":"auto","name":"auto"}]}}}\n' "$id" + ;; + *'"method":"session/load"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" + ;; + *'"method":"session/resume"'*) + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"session/resume should not be used for kiro"}}\n' "$id" + ;; + *'"method":"session/set_model"'*) + case "$line" in + *bogus-model*) + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id" + exit 0 + ;; + *) + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" + ;; + esac + ;; + *'"method":"session/prompt"'*) + case "$line" in + *'"content":'*) + ;; + *) + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"session/prompt must send content and prompt"}}\n' "$id" + exit 0 + ;; + esac + case "$line" in + *'"prompt":'*) + ;; + *) + printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"session/prompt must send content and prompt"}}\n' "$id" + exit 0 + ;; + esac + printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"loaded"}}}}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":2,"outputTokens":1}}}\n' "$id" + exit 0 + ;; + esac +done +` +} + +func TestKiroBackendSetModelFailureFailsTask(t *testing.T) { + t.Parallel() + + fakePath := filepath.Join(t.TempDir(), "kiro-cli") + writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) + + backend, err := New("kiro", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if err != nil { + t.Fatalf("new kiro backend: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ + Model: "bogus-model", + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + + select { + case result, ok := <-session.Result: + if !ok { + t.Fatal("result channel closed without a value") + } + if result.Status != "failed" { + t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error) + } + if !strings.Contains(result.Error, `could not switch to model "bogus-model"`) { + t.Errorf("expected error to name the requested model, got %q", result.Error) + } + if !strings.Contains(result.Error, "model not available") { + t.Errorf("expected error to surface upstream message, got %q", result.Error) + } + if result.SessionID != "ses_new" { + t.Errorf("expected session id to be preserved on failure, got %q", result.SessionID) + } + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for result") + } +} + +func TestKiroBackendInvokesACPWithTrustAllTools(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + argsFile := filepath.Join(tempDir, "argv.txt") + fakePath := filepath.Join(tempDir, "kiro-cli") + writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) + + backend, err := New("kiro", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"KIRO_ARGS_FILE": argsFile}, + }) + if err != nil { + t.Fatalf("new kiro backend: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{ + Model: "bogus-model", + Timeout: 5 * time.Second, + CustomArgs: []string{"acp", "--trust-tools", "shell", "-a", "--agent", "multica"}, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + <-session.Result + + raw, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args file: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + wantPrefix := []string{"acp", "--trust-all-tools"} + if len(lines) < len(wantPrefix) { + t.Fatalf("expected at least %d args, got %d: %q", len(wantPrefix), len(lines), lines) + } + for i, want := range wantPrefix { + if lines[i] != want { + t.Fatalf("arg[%d] = %q, want %q (full: %q)", i, lines[i], want, lines) + } + } + for _, blocked := range []string{"--trust-tools", "shell", "-a"} { + for _, got := range lines { + if got == blocked { + t.Errorf("protocol-critical custom arg %q was not filtered: %q", blocked, lines) + } + } + } + if strings.Join(lines, "\n") != strings.Join([]string{"acp", "--trust-all-tools", "--agent", "multica"}, "\n") { + t.Errorf("unexpected argv after filtering: %q", lines) + } +} + +func TestKiroBackendUsesSessionLoadForResume(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + requestsFile := filepath.Join(tempDir, "requests.jsonl") + fakePath := filepath.Join(tempDir, "kiro-cli") + writeTestExecutable(t, fakePath, []byte(fakeKiroACPScript())) + + backend, err := New("kiro", Config{ + ExecutablePath: fakePath, + Logger: slog.Default(), + Env: map[string]string{"KIRO_REQUESTS_FILE": requestsFile}, + }) + if err != nil { + t.Fatalf("new kiro backend: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + session, err := backend.Execute(ctx, "continue", ExecOptions{ + ResumeSessionID: "ses_existing", + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("execute: %v", err) + } + go func() { + for range session.Messages { + } + }() + + result := <-session.Result + if result.Status != "completed" { + t.Fatalf("expected completed result, got status=%q error=%q", result.Status, result.Error) + } + if result.Output != "loaded" { + t.Fatalf("output = %q, want loaded", result.Output) + } + if result.SessionID != "ses_existing" { + t.Fatalf("session id = %q, want ses_existing", result.SessionID) + } + + raw, err := os.ReadFile(requestsFile) + if err != nil { + t.Fatalf("read requests file: %v", err) + } + requests := string(raw) + if !strings.Contains(requests, `"method":"session/load"`) { + t.Fatalf("expected session/load request, got:\n%s", requests) + } + if strings.Contains(requests, `"method":"session/resume"`) { + t.Fatalf("kiro backend must not call session/resume, got:\n%s", requests) + } + if !strings.Contains(requests, `"mcpServers":[]`) { + t.Fatalf("session/load must include mcpServers, got:\n%s", requests) + } + // Kiro docs use content, but Kiro CLI 2.1.1 still requires prompt. + if !strings.Contains(requests, `"content":[`) { + t.Fatalf("session/prompt must send Kiro content field, got:\n%s", requests) + } + if !strings.Contains(requests, `"prompt":[`) { + t.Fatalf("session/prompt must send standard ACP prompt field for Kiro 2.1.1 compatibility, got:\n%s", requests) + } +} diff --git a/server/pkg/agent/models.go b/server/pkg/agent/models.go index 0a4b8857bf..9d5b80516e 100644 --- a/server/pkg/agent/models.go +++ b/server/pkg/agent/models.go @@ -75,6 +75,10 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod return cachedDiscovery(providerType, func() ([]Model, error) { return discoverKimiModels(ctx, executablePath) }) + case "kiro": + return cachedDiscovery(providerType, func() ([]Model, error) { + return discoverKiroModels(ctx, executablePath) + }) case "opencode": return cachedDiscovery(providerType, func() ([]Model, error) { return discoverOpenCodeModels(ctx, executablePath) @@ -341,10 +345,10 @@ func parsePiModels(output string) []Model { // creatable manual-entry input instead of blocking the form. func discoverHermesModels(ctx context.Context, executablePath string) ([]Model, error) { return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ - defaultBin: "hermes", - clientName: "multica-model-discovery", - extraEnv: []string{"HERMES_YOLO_MODE=1"}, - tmpdirPrefix: "multica-hermes-discovery-", + defaultBin: "hermes", + clientName: "multica-model-discovery", + extraEnv: []string{"HERMES_YOLO_MODE=1"}, + tmpdirPrefix: "multica-hermes-discovery-", }) } @@ -364,6 +368,16 @@ func discoverKimiModels(ctx context.Context, executablePath string) ([]Model, er }) } +// discoverKiroModels spins up a throwaway `kiro-cli acp` process and parses +// the models block Kiro returns from session/new. +func discoverKiroModels(ctx context.Context, executablePath string) ([]Model, error) { + return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ + defaultBin: "kiro-cli", + clientName: "multica-model-discovery", + tmpdirPrefix: "multica-kiro-discovery-", + }) +} + // acpDiscoveryProvider configures how discoverACPModels launches an // ACP-speaking agent CLI. The shared helper drives every CLI in // the same way (initialize → session/new → parse models block) — the diff --git a/server/pkg/agent/models_test.go b/server/pkg/agent/models_test.go index ac397183e0..78d32c4bb7 100644 --- a/server/pkg/agent/models_test.go +++ b/server/pkg/agent/models_test.go @@ -78,6 +78,21 @@ func TestListModelsHermesWithoutBinary(t *testing.T) { } } +func TestListModelsKiroWithoutBinary(t *testing.T) { + ctx := context.Background() + modelCacheMu.Lock() + delete(modelCache, "kiro") + modelCacheMu.Unlock() + + got, err := ListModels(ctx, "kiro", "/nonexistent/kiro-cli") + if err != nil { + t.Fatalf("ListModels(kiro) error: %v", err) + } + if got == nil { + t.Error("expected non-nil slice even when binary is missing") + } +} + func TestListModelsUnknownProvider(t *testing.T) { ctx := context.Background() _, err := ListModels(ctx, "nonexistent", "") From 1845eaf42c3a3031dc48b9cf2819b05a4b2115b3 Mon Sep 17 00:00:00 2001 From: devv-eve Date: Tue, 28 Apr 2026 17:21:30 +0800 Subject: [PATCH 34/38] fix: update kiro runtime icon (#1787) Co-authored-by: Devv --- .../runtimes/components/provider-logo.tsx | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx index 364283b284..4f22cdc4ae 100644 --- a/packages/views/runtimes/components/provider-logo.tsx +++ b/packages/views/runtimes/components/provider-logo.tsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import { Monitor } from "lucide-react"; // Claude (Anthropic) — official mark, sourced from Bootstrap Icons (bi-claude) @@ -125,13 +126,41 @@ function KimiLogo({ className }: { className: string }) { ); } -// Kiro CLI — compact "K" mark for runtime rows. +// Kiro CLI — official icon sourced from kiro.dev/icon.svg. function KiroLogo({ className }: { className: string }) { + const maskId = `kiro-logo-mask-${useId().replace(/:/g, "")}`; + return ( - - - - + + + + + + + + + + ); } From bbe73ade8bce34b7986a488cdfac79d4091ca00c Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:33:48 +0800 Subject: [PATCH 35/38] feat(desktop): dock unread badge + focus-gated inbox notifications (#1445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): dock unread badge + focus-gated inbox notifications Wire two OS-level integrations for inbox activity. Both degrade cleanly on web and unsupported platforms. - Unread badge on the macOS dock / Linux Unity launcher. Derived from the same inbox list the UI renders, deduplicated per issue, capped as "99+" on macOS via `app.dock.setBadge` (setBadgeCount truncates at 99). New `useInboxUnreadCount` hook (core/inbox) + `useDesktopUnreadBadge` (views/platform) keep renderer and main in sync via a `badge:set` IPC. - Native OS notification on `inbox:new`, fired from the renderer only when `document.hasFocus()` is false — in-focus feedback is the existing inbox sidebar's unread styling, so we don't fight macOS's deliberate foreground suppression. Clicking the banner focuses the main window and navigates to `/inbox?issue=` via the shared `multica:navigate` bus. Refactors `inbox-page.tsx` to read the unread count through the new hook (was a per-render inline filter). * fix(desktop): pin notification routing to source workspace + mark read on URL select Two bugs GPT-Boy caught on PR #1445: 1. A notification from workspace A used `getCurrentSlug()` at click time, so if the user switched to workspace B before clicking the banner (macOS Notification Center persists banners), routing landed on `/B/inbox?issue=` and 404'd. Fix: round-trip the emit-time `slug` through the IPC payload and use it in the click handler. 2. Notification-click navigation set the URL param but never fired the mark-read mutation (only InboxPage's click-handler did). The row stayed unread and the dock badge didn't decrement. Fix: move the mark-read logic from handleSelect into a useEffect keyed on the selected item — it now covers both click-to-select and URL-param-select. IPC payload gains `slug` and `itemId`; preload types + main handler + the desktop bridge are updated to match. --- apps/desktop/src/main/index.ts | 60 ++++++++++++++++++- apps/desktop/src/preload/index.d.ts | 18 ++++++ apps/desktop/src/preload/index.ts | 44 ++++++++++++++ .../src/components/desktop-layout.tsx | 36 ++++++++++- packages/core/inbox/queries.ts | 18 +++++- packages/core/realtime/use-realtime-sync.ts | 35 +++++++++++ .../views/inbox/components/inbox-page.tsx | 24 +++++--- packages/views/platform/index.ts | 1 + .../platform/use-desktop-unread-badge.ts | 23 +++++++ 9 files changed, 249 insertions(+), 10 deletions(-) create mode 100644 packages/views/platform/use-desktop-unread-badge.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 970cafcde0..d29470e5d1 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, nativeImage } from "electron"; +import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron"; import { homedir } from "os"; import { join } from "path"; import { electronApp, optimizer, is } from "@electron-toolkit/utils"; @@ -214,6 +214,64 @@ if (!gotTheLock) { mainWindow?.setWindowButtonVisibility(!immersive); }); + // IPC: show a native OS notification for a new inbox item. The renderer + // only fires this when the app is unfocused (it gates on + // `document.hasFocus()`), so we don't fight macOS foreground suppression + // here. Clicking the banner focuses the main window and routes to the + // inbox item via a renderer-side listener. + ipcMain.on( + "notification:show", + ( + _event, + { + slug, + itemId, + issueKey, + title, + body, + }: { + slug: string; + itemId: string; + issueKey: string; + title: string; + body: string; + }, + ) => { + if (!Notification.isSupported()) return; + const notification = new Notification({ title, body }); + notification.on("click", () => { + if (!mainWindow) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + // Ship the full context back — the renderer pins the route to the + // source workspace (slug), marks the row read (itemId), and uses + // issueKey as the ?issue=<…> selector. + mainWindow.webContents.send("inbox:open", { + slug, + itemId, + issueKey, + }); + }); + notification.show(); + }, + ); + + // IPC: update the dock / taskbar unread badge. Values above 99 render as + // "99+". macOS is the primary target (user-visible dock badge); Linux + // Unity launchers also respect `setBadgeCount`. Windows' taskbar overlay + // needs a pre-rendered PNG and is deferred — the OS notification + the + // in-app inbox sidebar cover the core UX there for now. + ipcMain.on("badge:set", (_event, rawCount: number) => { + const count = Math.max(0, Math.floor(rawCount)); + if (process.platform === "darwin") { + const label = count === 0 ? "" : count > 99 ? "99+" : String(count); + app.dock?.setBadge(label); + } else { + app.setBadgeCount(count); + } + }); + createWindow(); setupAutoUpdater(() => mainWindow); diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index f7c0b376d5..fc4cb4c025 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -14,6 +14,24 @@ interface DesktopAPI { openExternal: (url: string) => Promise; /** Hide macOS traffic lights for full-screen modals; restore when false. */ setImmersiveMode: (immersive: boolean) => Promise; + /** Show a native OS notification for a new inbox item. */ + showNotification: (payload: { + slug: string; + itemId: string; + issueKey: string; + title: string; + body: string; + }) => void; + /** Update the OS dock / taskbar unread badge. Pass 0 to clear. */ + setUnreadBadge: (count: number) => void; + /** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */ + onInboxOpen: ( + callback: (payload: { + slug: string; + itemId: string; + issueKey: string; + }) => void, + ) => () => void; } interface DaemonStatus { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index ca3024e5e5..0c0d92f595 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -50,6 +50,50 @@ const desktopAPI = { /** Toggle immersive mode — hide macOS traffic lights for full-screen modals */ setImmersiveMode: (immersive: boolean) => ipcRenderer.invoke("window:setImmersive", immersive), + /** + * Show a native OS notification for a new inbox item. Fired from the + * renderer only when the app is unfocused — in-focus feedback is the + * inbox sidebar's unread styling. `slug`, `itemId`, and `issueKey` are + * all round-tripped on click: slug pins routing to the source workspace + * (the user may switch workspaces before clicking the banner), itemId + * lets the renderer mark the row read, issueKey maps to the inbox URL + * param. + */ + showNotification: (payload: { + slug: string; + itemId: string; + issueKey: string; + title: string; + body: string; + }) => ipcRenderer.send("notification:show", payload), + /** + * Update the OS dock / taskbar unread badge. Pass 0 to clear. Values + * above 99 render as "99+" (capping is handled in the main process). + */ + setUnreadBadge: (count: number) => + ipcRenderer.send("badge:set", Math.max(0, Math.floor(count))), + /** + * Subscribe to "open this inbox row" requests sent by the main process + * when the user clicks an OS notification banner. Returns an unsubscribe + * function. The payload echoes the `slug`, `itemId`, and `issueKey` that + * were passed to `showNotification`. + */ + onInboxOpen: ( + callback: (payload: { + slug: string; + itemId: string; + issueKey: string; + }) => void, + ) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: { slug: string; itemId: string; issueKey: string }, + ) => callback(payload); + ipcRenderer.on("inbox:open", handler); + return () => { + ipcRenderer.removeListener("inbox:open", handler); + }; + }, }; interface DaemonStatus { diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx index f8ecfddf9b..63e4d37f48 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx @@ -13,8 +13,9 @@ import { ModalRegistry } from "@multica/views/modals/registry"; import { AppSidebar } from "@multica/views/layout"; import { SearchCommand, SearchTrigger } from "@multica/views/search"; import { StarterContentPrompt } from "@multica/views/onboarding"; -import { WorkspaceSlugProvider } from "@multica/core/paths"; +import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths"; import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform"; +import { useDesktopUnreadBadge } from "@multica/views/platform"; import { DesktopNavigationProvider } from "@/platform/navigation"; import { TabBar } from "./tab-bar"; import { TabContent } from "./tab-content"; @@ -96,6 +97,38 @@ function useInternalLinkHandler() { }, []); } +/** + * Bridge between the renderer and the Electron main process for inbox-level + * OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the + * current workspace's id for the badge hook. + * + * Two responsibilities: + * 1. Mirror the unread inbox count onto the dock/taskbar badge. + * 2. When the user clicks an OS notification, open the notified + * workspace's inbox focused on that item. The route uses the `slug` + * that the notification was *emitted* with — not the currently active + * workspace — so a notification from workspace A always opens A's + * inbox even if the user has since switched to workspace B. Marking + * the row read is handled by InboxPage's selected-item effect, which + * covers both click-to-select and URL-param-select paths. + */ +function DesktopInboxBridge() { + const workspace = useCurrentWorkspace(); + useDesktopUnreadBadge(workspace?.id ?? null); + + useEffect(() => { + return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => { + if (!slug) return; + const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`; + window.dispatchEvent( + new CustomEvent("multica:navigate", { detail: { path: inboxPath } }), + ); + }); + }, []); + + return null; +} + export function DesktopShell() { useInternalLinkHandler(); useActiveTitleSync(); @@ -117,6 +150,7 @@ export function DesktopShell() { users see the window-level overlay (new-workspace flow) triggered by IndexRedirect, not a route. */} +

{slug && } searchSlot={} />} diff --git a/packages/core/inbox/queries.ts b/packages/core/inbox/queries.ts index f5971a53c0..23a3f1be79 100644 --- a/packages/core/inbox/queries.ts +++ b/packages/core/inbox/queries.ts @@ -1,4 +1,4 @@ -import { queryOptions } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { api } from "../api"; import type { InboxItem } from "../types"; @@ -14,6 +14,22 @@ export function inboxListOptions(wsId: string) { }); } +/** + * Unread inbox count for the given workspace, aligned with what the inbox + * list UI renders: archived items excluded, then deduplicated by issue so a + * single issue with three unread notifications counts once. + */ +export function useInboxUnreadCount(wsId: string | null | undefined): number { + const { data } = useQuery({ + queryKey: inboxKeys.list(wsId ?? ""), + queryFn: () => api.listInbox(), + enabled: !!wsId, + select: (items: InboxItem[]) => + deduplicateInboxItems(items).filter((i) => !i.read).length, + }); + return data ?? 0; +} + /** * Deduplicate inbox items by issue_id (one entry per issue, Linear-style). * Exported for consumers to use in useMemo — not in queryOptions select diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index d6ed19c38a..162b27db0d 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -225,6 +225,41 @@ export function useRealtimeSync( if (!item) return; const wsId = getCurrentWsId(); if (wsId) onInboxNew(qc, wsId, item); + // Fire a native OS notification only when the app isn't focused. When + // the user is already looking at Multica, the inbox sidebar's unread + // styling is enough — no need to interrupt with a banner. `desktopAPI` + // is injected by the preload script; its absence (web app) skips silently. + if (typeof document !== "undefined" && document.hasFocus()) return; + // Capture the source workspace slug at emit time. The user may switch + // workspaces before clicking the banner (macOS Notification Center + // holds banners), so routing must not read "current slug" at click + // time — otherwise notifications from workspace A click through to + // workspace B's inbox and 404. + const slug = getCurrentSlug(); + if (!slug) return; + const desktopAPI = ( + window as unknown as { + desktopAPI?: { + showNotification?: (payload: { + slug: string; + itemId: string; + issueKey: string; + title: string; + body: string; + }) => void; + }; + } + ).desktopAPI; + // `issueKey` matches the inbox page's URL selector (issue id when the + // item is attached to an issue, otherwise the inbox item id). `itemId` + // is the inbox row's own id, needed to fire markInboxRead on click. + desktopAPI?.showNotification?.({ + slug, + itemId: item.id, + issueKey: item.issue_id ?? item.id, + title: item.title, + body: item.body ?? "", + }); }); // --- Timeline event handlers (global fallback) --- diff --git a/packages/views/inbox/components/inbox-page.tsx b/packages/views/inbox/components/inbox-page.tsx index f28bed72db..f1a912802c 100644 --- a/packages/views/inbox/components/inbox-page.tsx +++ b/packages/views/inbox/components/inbox-page.tsx @@ -8,6 +8,7 @@ import { useWorkspacePaths } from "@multica/core/paths"; import { inboxListOptions, deduplicateInboxItems, + useInboxUnreadCount, } from "@multica/core/inbox/queries"; import { useMarkInboxRead, @@ -105,7 +106,7 @@ export function InboxPage() { }); const isMobile = useIsMobile(); - const unreadCount = items.filter((i) => !i.read).length; + const unreadCount = useInboxUnreadCount(wsId); const markReadMutation = useMarkInboxRead(); const archiveMutation = useArchiveInbox(); @@ -114,14 +115,23 @@ export function InboxPage() { const archiveAllReadMutation = useArchiveAllReadInbox(); const archiveCompletedMutation = useArchiveCompletedInbox(); - // Click-to-read: select + auto-mark-read + // Auto-mark-read whenever a selected item is unread — covers both click- + // to-select and URL-param-select (e.g. OS notification click on desktop). + // The mutation flips `read: true` optimistically, so this effect settles + // in one pass and can't loop. Kept in a `useEffect` rather than inlined + // in handleSelect so URL-driven selection triggers it too. + const markReadMutate = markReadMutation.mutate; + const selectedId = selected?.id; + const selectedRead = selected?.read; + useEffect(() => { + if (!selectedId || selectedRead) return; + markReadMutate(selectedId, { + onError: () => toast.error("Failed to mark as read"), + }); + }, [selectedId, selectedRead, markReadMutate]); + const handleSelect = (item: InboxItem) => { setSelectedKey(item.issue_id ?? item.id); - if (!item.read) { - markReadMutation.mutate(item.id, { - onError: () => toast.error("Failed to mark as read"), - }); - } }; const handleArchive = (id: string) => { diff --git a/packages/views/platform/index.ts b/packages/views/platform/index.ts index 9633eadea2..6ae77075b7 100644 --- a/packages/views/platform/index.ts +++ b/packages/views/platform/index.ts @@ -1,3 +1,4 @@ export { useImmersiveMode } from "./use-immersive-mode"; +export { useDesktopUnreadBadge } from "./use-desktop-unread-badge"; export { DragStrip } from "./drag-strip"; export { openExternal } from "./open-external"; diff --git a/packages/views/platform/use-desktop-unread-badge.ts b/packages/views/platform/use-desktop-unread-badge.ts new file mode 100644 index 0000000000..91801390e9 --- /dev/null +++ b/packages/views/platform/use-desktop-unread-badge.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useInboxUnreadCount } from "@multica/core/inbox/queries"; + +type BadgeCapableAPI = { + setUnreadBadge?: (count: number) => void; +}; + +function getDesktopAPI(): BadgeCapableAPI | undefined { + if (typeof window === "undefined") return undefined; + return (window as unknown as { desktopAPI?: BadgeCapableAPI }).desktopAPI; +} + +/** + * Mirror the inbox unread count onto the OS dock/taskbar badge. No-op on web + * (no `desktopAPI`) and on the login screen (no workspace ⇒ count defaults + * to 0, which clears any stale badge from a previous session). + */ +export function useDesktopUnreadBadge(wsId: string | null | undefined): void { + const count = useInboxUnreadCount(wsId); + useEffect(() => { + getDesktopAPI()?.setUnreadBadge?.(count); + }, [count]); +} From 6f9e82cecc918b98b256ff5ae560c47de4375c04 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:46:28 +0800 Subject: [PATCH 36/38] docs(changelog): publish v0.2.19 release notes (#1791) * docs(changelog): publish v0.2.19 release notes Today's release covers 23 commits since v0.2.18. Headline items are the macOS dock unread badge with focus-gated inbox notifications, the daemon WebSocket task wakeup path that drops task startup latency, and a client-side label filter on the issue list. Improvements / fixes round out comment linkify, optimistic label attach, agent-to-agent mention loop prevention, Codex turn timeouts, Windows daemon survivability, and the comment-delete task cancellation. The Kiro CLI runtime addition is intentionally omitted pending a chat-mode regression flagged before release. * docs(changelog): include Kiro CLI runtime, drop assignee-default line Per release sign-off: Kiro CLI ACP runtime ships in v0.2.19 once the chat-mode regression is fixed, so it goes back into the headline. The "create-issue remembers last assignee" line is dropped from features to keep the list to four spotlight items. --- apps/web/features/landing/i18n/en.ts | 24 ++++++++++++++++++++++++ apps/web/features/landing/i18n/zh.ts | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 1793b1f082..8529ba4798 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -283,6 +283,30 @@ export function createEnDict(allowSignup: boolean): LandingDict { fixes: "Bug Fixes", }, entries: [ + { + version: "0.2.19", + date: "2026-04-28", + title: "Kiro CLI Runtime, Desktop Notifications & Issue Label Filter", + changes: [], + features: [ + "Kiro CLI added as a local agent runtime option", + "macOS dock badge for unread issues, plus a native notification when the window is unfocused — click to jump straight to the issue", + "Issue list now supports filtering by label, combinable with status / priority / assignee", + "Daemon receives task wakeups over WebSocket — task startup latency drops noticeably", + ], + improvements: [ + "List and board status group headers are simpler, with clearer color cues", + "Author-written markdown links are preserved through linkify", + "Label attach now applies optimistically, no server round-trip wait", + "Mention picker's issue search refreshes as you type", + ], + fixes: [ + "Deleting a comment now cancels any agent task it triggered — no more ghost runs", + "Stalled Codex turns now time out instead of holding the slot", + "Windows daemon no longer dies when the parent shell closes", + "Agent-to-agent mention threads no longer cause feedback loops", + ], + }, { version: "0.2.18", date: "2026-04-27", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index a81fa76878..2ac6b8d019 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -283,6 +283,30 @@ export function createZhDict(allowSignup: boolean): LandingDict { fixes: "问题修复", }, entries: [ + { + version: "0.2.19", + date: "2026-04-28", + title: "Kiro CLI Runtime、桌面通知红点与 Issue 标签过滤", + changes: [], + features: [ + "新增 Kiro CLI 作为本地 Agent runtime 选项", + "macOS Dock 显示未读 Issue 红点;窗口失焦时弹出原生通知,点击直达对应 Issue", + "Issue 列表新增 Label 过滤,可与状态、优先级、Assignee 等组合使用", + "Daemon 通过 WebSocket 接收任务唤醒,任务起跑延迟显著降低", + ], + improvements: [ + "List/Board 视图的状态分组 header 更简洁,颜色提示更清晰", + "评论中作者手写的 Markdown 链接不再被自动 linkify 替换", + "添加 Label 现在乐观更新,无需等待服务端往返", + "Mention 输入时的 Issue 搜索结果会随着输入实时刷新", + ], + fixes: [ + "Comment 被删除时会取消已触发的 Agent 任务,不再有幽灵 run", + "Codex 卡住的对话回合会超时退出,避免占用配额", + "Windows Daemon 不再随父 shell 关闭被一同杀掉", + "Agent 之间的 mention 不再相互触发,避免死循环", + ], + }, { version: "0.2.18", date: "2026-04-27", From 03f3180b8fddce3bdc935a82302c78cd0e37e2cf Mon Sep 17 00:00:00 2001 From: LinYushen Date: Tue, 28 Apr 2026 17:50:13 +0800 Subject: [PATCH 37/38] fix(agent): ignore Kiro session/load history replay (#1789) Ignore Kiro ACP session/load history replay before the active prompt starts; keep task messages, usage, and tool state scoped to the current Kiro turn. Verified with go test ./pkg/agent -run TestKiro, go test ./pkg/agent, and git diff --check origin/main...HEAD. --- server/pkg/agent/hermes.go | 6 ++++++ server/pkg/agent/kiro.go | 12 ++++++++++++ server/pkg/agent/kiro_test.go | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/server/pkg/agent/hermes.go b/server/pkg/agent/hermes.go index c4c9193e42..b25409b556 100644 --- a/server/pkg/agent/hermes.go +++ b/server/pkg/agent/hermes.go @@ -343,6 +343,9 @@ type hermesClient struct { sessionID string onMessage func(Message) onPromptDone func(hermesPromptResult) + // acceptNotification can drop ACP session updates before dispatching to + // handlers that mutate client state such as usage or pending tool calls. + acceptNotification func(updateType string) bool // pendingTools buffers the args for tool calls whose input streams in // across multiple ACP tool_call_update messages (kimi does this — @@ -603,6 +606,9 @@ func (c *hermesClient) handleNotification(raw map[string]json.RawMessage) { } updateType, updateData := normalizeACPUpdate(params.Update) + if c.acceptNotification != nil && !c.acceptNotification(updateType) { + return + } switch updateType { case "agent_message_chunk": diff --git a/server/pkg/agent/kiro.go b/server/pkg/agent/kiro.go index f279f50645..e8e7197182 100644 --- a/server/pkg/agent/kiro.go +++ b/server/pkg/agent/kiro.go @@ -8,6 +8,7 @@ import ( "os/exec" "strings" "sync" + "sync/atomic" "time" ) @@ -83,6 +84,7 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio var outputMu sync.Mutex var output strings.Builder + var streamingCurrentTurn atomic.Bool promptDone := make(chan hermesPromptResult, 1) @@ -91,7 +93,13 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio stdin: stdin, pending: make(map[int]*pendingRPC), pendingTools: make(map[string]*pendingToolCall), + acceptNotification: func(string) bool { + return streamingCurrentTurn.Load() + }, onMessage: func(msg Message) { + if !streamingCurrentTurn.Load() { + return + } if msg.Type == MessageToolUse { msg.Tool = kiroToolNameFromTitle(msg.Tool) } @@ -103,6 +111,9 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio trySend(msgCh, msg) }, onPromptDone: func(result hermesPromptResult) { + if !streamingCurrentTurn.Load() { + return + } select { case promptDone <- result: default: @@ -226,6 +237,7 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio // requires the standard ACP `prompt` field. Send both so either wire // shape can drive the turn. // TODO: drop one field once Kiro lands on a single canonical payload. + streamingCurrentTurn.Store(true) _, err = c.request(runCtx, "session/prompt", map[string]any{ "sessionId": sessionID, "content": promptBlocks, diff --git a/server/pkg/agent/kiro_test.go b/server/pkg/agent/kiro_test.go index acf7915f44..ec7a77b784 100644 --- a/server/pkg/agent/kiro_test.go +++ b/server/pkg/agent/kiro_test.go @@ -67,6 +67,9 @@ while IFS= read -r line; do printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_new","models":{"currentModelId":"auto","availableModels":[{"modelId":"auto","name":"auto"}]}}}\n' "$id" ;; *'"method":"session/load"'*) + printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"history should be ignored"}}}}\n' + printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"UsageUpdate","usage":{"inputTokens":1000,"outputTokens":1000,"cachedReadTokens":100}}}}\n' + printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"ToolCall","toolCallId":"tc-current","name":"Shell","status":"pending","parameters":{"command":"echo replay"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;; *'"method":"session/resume"'*) @@ -100,6 +103,7 @@ while IFS= read -r line; do exit 0 ;; esac + printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"ToolCallUpdate","toolCallId":"tc-current","status":"completed","name":"Shell","parameters":{"command":"echo current"},"output":"current tool output\\n"}}}\n' printf '{"jsonrpc":"2.0","method":"session/notification","params":{"sessionId":"ses_loaded","update":{"type":"AgentMessageChunk","content":{"type":"text","text":"loaded"}}}}\n' printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn","usage":{"inputTokens":2,"outputTokens":1}}}\n' "$id" exit 0 @@ -244,18 +248,47 @@ func TestKiroBackendUsesSessionLoadForResume(t *testing.T) { if err != nil { t.Fatalf("execute: %v", err) } + var messages []Message + messagesDone := make(chan struct{}) go func() { - for range session.Messages { + defer close(messagesDone) + for msg := range session.Messages { + messages = append(messages, msg) } }() result := <-session.Result + <-messagesDone if result.Status != "completed" { t.Fatalf("expected completed result, got status=%q error=%q", result.Status, result.Error) } if result.Output != "loaded" { t.Fatalf("output = %q, want loaded", result.Output) } + if usage := result.Usage["unknown"]; usage.InputTokens != 2 || usage.OutputTokens != 1 || usage.CacheReadTokens != 0 { + t.Fatalf("usage = %+v, want input=2 output=1 cache_read=0", usage) + } + if len(messages) != 3 { + t.Fatalf("messages = %+v, want current tool use, tool result, and text only", messages) + } + if messages[0].Type != MessageToolUse { + t.Fatalf("messages[0].Type = %v, want MessageToolUse", messages[0].Type) + } + if messages[0].Tool != "terminal" { + t.Fatalf("messages[0].Tool = %q, want terminal", messages[0].Tool) + } + if command, _ := messages[0].Input["command"].(string); command != "echo current" { + t.Fatalf("messages[0].Input[command] = %q, want echo current", command) + } + if messages[1].Type != MessageToolResult { + t.Fatalf("messages[1].Type = %v, want MessageToolResult", messages[1].Type) + } + if messages[1].Output != "current tool output\n" { + t.Fatalf("messages[1].Output = %q, want current tool output", messages[1].Output) + } + if messages[2].Type != MessageText || messages[2].Content != "loaded" { + t.Fatalf("messages[2] = %+v, want text loaded", messages[2]) + } if result.SessionID != "ses_existing" { t.Fatalf("session id = %q, want ses_existing", result.SessionID) } From 01855f6b09f37879dc4f0f98015668d9206d1bf5 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:31:33 +0800 Subject: [PATCH 38/38] =?UTF-8?q?revert(chat):=20Chat=20V2=20=E2=80=94=20r?= =?UTF-8?q?estore=20right-bottom=20floating=20drawer=20(#1580)=20(#1792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "fix(chat): prevent UI flicker when streaming response finalizes (#1583)" This reverts commit 71cc646951be16fd5be3bb2df7fe103e90c5083d. * Revert "fix(chat): prevent chatbox jump when sending first message (#1582)" This reverts commit bb767e0ea65e1a3daaa1c59493a0e1eedf8aa2a3. * Revert "feat(chat): Chat V2 — sidebar entry + main-area page (#1580)" This reverts commit 35aca57939b5f4eae88a24023fa281e6f18c1180. --- .../src/components/desktop-layout.tsx | 5 +- .../src/renderer/src/components/tab-bar.tsx | 2 - .../src/renderer/src/platform/navigation.tsx | 8 +- apps/desktop/src/renderer/src/routes.tsx | 2 - .../src/renderer/src/stores/tab-store.ts | 1 - .../[workspaceSlug]/(dashboard)/chat/page.tsx | 1 - .../[workspaceSlug]/(dashboard)/layout.tsx | 3 + apps/web/next-env.d.ts | 2 +- packages/core/chat/index.ts | 2 +- packages/core/chat/store.ts | 70 +- packages/core/paths/consistency.test.ts | 2 - packages/core/paths/paths.ts | 1 - packages/core/realtime/use-realtime-sync.ts | 2 +- packages/views/chat/components/chat-fab.tsx | 63 ++ packages/views/chat/components/chat-input.tsx | 2 +- .../chat/components/chat-message-list.tsx | 58 +- packages/views/chat/components/chat-page.tsx | 508 -------------- .../chat/components/chat-resize-handles.tsx | 34 + .../chat/components/chat-session-history.tsx | 148 ++++ .../views/chat/components/chat-window.tsx | 648 ++++++++++++++++++ .../views/chat/components/context-anchor.tsx | 50 +- .../views/chat/components/use-chat-resize.ts | 140 ++++ packages/views/chat/index.ts | 3 +- packages/views/editor/utils/link-handler.ts | 1 - packages/views/layout/app-sidebar.tsx | 29 - packages/views/layout/dashboard-layout.tsx | 2 +- 26 files changed, 1136 insertions(+), 651 deletions(-) delete mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx create mode 100644 packages/views/chat/components/chat-fab.tsx delete mode 100644 packages/views/chat/components/chat-page.tsx create mode 100644 packages/views/chat/components/chat-resize-handles.tsx create mode 100644 packages/views/chat/components/chat-session-history.tsx create mode 100644 packages/views/chat/components/chat-window.tsx create mode 100644 packages/views/chat/components/use-chat-resize.ts diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx index 63e4d37f48..54896fe725 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx @@ -12,6 +12,7 @@ import { import { ModalRegistry } from "@multica/views/modals/registry"; import { AppSidebar } from "@multica/views/layout"; import { SearchCommand, SearchTrigger } from "@multica/views/search"; +import { ChatFab, ChatWindow } from "@multica/views/chat"; import { StarterContentPrompt } from "@multica/views/onboarding"; import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths"; import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform"; @@ -157,9 +158,11 @@ export function DesktopShell() { {/* Right side: header + content container */}
- {/* Content area with inset styling */} + {/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
+ {slug && } + {slug && }
diff --git a/apps/desktop/src/renderer/src/components/tab-bar.tsx b/apps/desktop/src/renderer/src/components/tab-bar.tsx index 12ac363933..93d139ac65 100644 --- a/apps/desktop/src/renderer/src/components/tab-bar.tsx +++ b/apps/desktop/src/renderer/src/components/tab-bar.tsx @@ -5,7 +5,6 @@ import { Bot, Monitor, BookOpenText, - MessageSquare, Settings, X, Plus, @@ -40,7 +39,6 @@ const TAB_ICONS: Record = { Bot, Monitor, BookOpenText, - MessageSquare, Settings, }; diff --git a/apps/desktop/src/renderer/src/platform/navigation.tsx b/apps/desktop/src/renderer/src/platform/navigation.tsx index 01043331a9..494916ed59 100644 --- a/apps/desktop/src/renderer/src/platform/navigation.tsx +++ b/apps/desktop/src/renderer/src/platform/navigation.tsx @@ -115,10 +115,10 @@ export function DesktopNavigationProvider({ const { tabId: activeTabId } = useActiveTabIdentity(); const router = useActiveTabRouter(); // Mirror the active tab router's full location (pathname + search) so - // shell-level consumers of useNavigation() can read URL search params. - // Must stay in sync with TabNavigationProvider below; a partial shape - // here (just pathname) silently broke focus-mode anchor resolution on - // `/inbox?issue=…`. + // shell-level consumers of useNavigation() — ChatWindow in particular — + // can read URL search params. Must stay in sync with TabNavigationProvider + // below; a partial shape here (just pathname) silently broke focus-mode + // anchor resolution on `/inbox?issue=…`. const [location, setLocation] = useState<{ pathname: string; search: string }>( () => ({ pathname: router?.state.location.pathname ?? "/", diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx index 794d07153a..06dd0b81c2 100644 --- a/apps/desktop/src/renderer/src/routes.tsx +++ b/apps/desktop/src/renderer/src/routes.tsx @@ -18,7 +18,6 @@ import { SkillsPage } from "@multica/views/skills"; import { DesktopRuntimesPage } from "./components/desktop-runtimes-page"; import { AgentsPage } from "@multica/views/agents"; import { InboxPage } from "@multica/views/inbox"; -import { ChatPage } from "@multica/views/chat"; import { SettingsPage } from "@multica/views/settings"; import { Download, Server } from "lucide-react"; import { DaemonSettingsTab } from "./components/daemon-settings-tab"; @@ -126,7 +125,6 @@ export const appRoutes: RouteObject[] = [ }, { path: "agents", element: , handle: { title: "Agents" } }, { path: "inbox", element: , handle: { title: "Inbox" } }, - { path: "chat", element: , handle: { title: "Chat" } }, { path: "settings", element: ( diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts index d31454328b..6ca66893d8 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.ts @@ -101,7 +101,6 @@ interface TabStore { const ROUTE_ICONS: Record = { inbox: "Inbox", - chat: "MessageSquare", "my-issues": "CircleUser", issues: "ListTodo", projects: "FolderKanban", diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx deleted file mode 100644 index dfeb9b95c0..0000000000 --- a/apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ChatPage as default } from "@multica/views/chat"; diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx index 2a4a7fe690..43769cc98e 100644 --- a/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx +++ b/apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx @@ -3,6 +3,7 @@ import { DashboardLayout } from "@multica/views/layout"; import { MulticaIcon } from "@multica/ui/components/common/multica-icon"; import { SearchCommand, SearchTrigger } from "@multica/views/search"; +import { ChatFab, ChatWindow } from "@multica/views/chat"; import { StarterContentPrompt } from "@multica/views/onboarding"; export default function Layout({ children }: { children: React.ReactNode }) { @@ -13,6 +14,8 @@ export default function Layout({ children }: { children: React.ReactNode }) { extra={ <> + + } diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818fbb..9edff1c7ca 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/chat/index.ts b/packages/core/chat/index.ts index 476caac776..9fd534a27f 100644 --- a/packages/core/chat/index.ts +++ b/packages/core/chat/index.ts @@ -1,4 +1,4 @@ -export { createChatStore, DRAFT_NEW_SESSION } from "./store"; +export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store"; export type { ChatStoreOptions, ChatState, ChatTimelineItem, ContextAnchor } from "./store"; import type { createChatStore as CreateChatStoreFn } from "./store"; diff --git a/packages/core/chat/store.ts b/packages/core/chat/store.ts index 52d9796273..22dde41162 100644 --- a/packages/core/chat/store.ts +++ b/packages/core/chat/store.ts @@ -11,6 +11,9 @@ const SESSION_STORAGE_KEY = "multica:chat:activeSessionId"; const DRAFTS_KEY = "multica:chat:drafts"; /** Placeholder sessionId for a chat that hasn't been created yet. */ export const DRAFT_NEW_SESSION = "__new__"; +const CHAT_WIDTH_KEY = "multica:chat:width"; +const CHAT_HEIGHT_KEY = "multica:chat:height"; +const CHAT_EXPANDED_KEY = "multica:chat:expanded"; /** Focus mode is a personal preference — global across workspaces/sessions. */ const FOCUS_MODE_KEY = "multica:chat:focusMode"; @@ -38,6 +41,11 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record; /** @@ -78,20 +88,22 @@ export interface ChatState { * the preference survives workspace switches and reloads. */ focusMode: boolean; - /** - * Last location where a context anchor could be derived (issue/project/inbox). - * Updated globally by useAnchorTracker; used as a fallback for the Chat page - * which is its own route and therefore has no anchor of its own. - * Not persisted — resets per session; focus mode itself persists. - */ - lastAnchorLocation: { pathname: string; search: string } | null; + /** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */ + chatWidth: number; + chatHeight: number; + isExpanded: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; setActiveSession: (id: string | null) => void; setSelectedAgentId: (id: string) => void; + setShowHistory: (show: boolean) => void; /** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */ setInputDraft: (sessionId: string, draft: string) => void; clearInputDraft: (sessionId: string) => void; setFocusMode: (on: boolean) => void; - setLastAnchorLocation: (loc: { pathname: string; search: string } | null) => void; + /** Persist raw size and auto-exit expanded mode. */ + setChatSize: (width: number, height: number) => void; + setExpanded: (expanded: boolean) => void; } export interface ChatStoreOptions { @@ -107,12 +119,24 @@ export function createChatStore(options: ChatStoreOptions) { }; const store = create((set, get) => ({ + isOpen: false, activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)), selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)), + showHistory: false, inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)), focusMode: storage.getItem(FOCUS_MODE_KEY) === "true", - lastAnchorLocation: null, - setLastAnchorLocation: (loc) => set({ lastAnchorLocation: loc }), + chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W, + chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H, + isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true", + setOpen: (open) => { + logger.debug("setOpen", { from: get().isOpen, to: open }); + set({ isOpen: open }); + }, + toggle: () => { + const next = !get().isOpen; + logger.debug("toggle", { to: next }); + set({ isOpen: next }); + }, setActiveSession: (id) => { logger.info("setActiveSession", { from: get().activeSessionId, to: id }); if (id) { @@ -127,6 +151,10 @@ export function createChatStore(options: ChatStoreOptions) { storage.setItem(wsKey(AGENT_STORAGE_KEY), id); set({ selectedAgentId: id }); }, + setShowHistory: (show) => { + logger.debug("setShowHistory", { to: show }); + set({ showHistory: show }); + }, setInputDraft: (sessionId, draft) => { // Debug level — onUpdate fires on every keystroke. logger.debug("setInputDraft", { sessionId, length: draft.length }); @@ -152,6 +180,23 @@ export function createChatStore(options: ChatStoreOptions) { writeDrafts(storage, wsKey(DRAFTS_KEY), next); set({ inputDrafts: next }); }, + setChatSize: (w, h) => { + logger.debug("setChatSize", { w, h }); + storage.setItem(CHAT_WIDTH_KEY, String(w)); + storage.setItem(CHAT_HEIGHT_KEY, String(h)); + // Dragging = user chose a manual size → exit expanded mode + storage.removeItem(wsKey(CHAT_EXPANDED_KEY)); + set({ chatWidth: w, chatHeight: h, isExpanded: false }); + }, + setExpanded: (expanded) => { + logger.info("setExpanded", { to: expanded }); + if (expanded) { + storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true"); + } else { + storage.removeItem(wsKey(CHAT_EXPANDED_KEY)); + } + set({ isExpanded: expanded }); + }, })); registerForWorkspaceRehydration(() => { @@ -165,15 +210,10 @@ export function createChatStore(options: ChatStoreOptions) { nextAgent, draftCount: Object.keys(nextDrafts).length, }); - // lastAnchorLocation is not persisted — reset it here so a pathname - // captured in the previous workspace can't be reused against the new - // workspace's wsId (would trigger a cross-workspace issue/project fetch - // and silently leak context into chat messages). store.setState({ activeSessionId: nextSession, selectedAgentId: nextAgent, inputDrafts: nextDrafts, - lastAnchorLocation: null, }); }); diff --git a/packages/core/paths/consistency.test.ts b/packages/core/paths/consistency.test.ts index 838c5f2c9f..3b4bc890a6 100644 --- a/packages/core/paths/consistency.test.ts +++ b/packages/core/paths/consistency.test.ts @@ -22,7 +22,6 @@ describe("paths.workspace() shape", () => { "autopilots", "agents", "inbox", - "chat", "myIssues", "runtimes", "skills", @@ -41,7 +40,6 @@ describe("paths.workspace() shape", () => { ["autopilots", "autopilots"], ["agents", "agents"], ["inbox", "inbox"], - ["chat", "chat"], ["myIssues", "my-issues"], ["runtimes", "runtimes"], ["skills", "skills"], diff --git a/packages/core/paths/paths.ts b/packages/core/paths/paths.ts index eb6e9a60a2..a7c20860f0 100644 --- a/packages/core/paths/paths.ts +++ b/packages/core/paths/paths.ts @@ -26,7 +26,6 @@ function workspaceScoped(slug: string) { autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`, agents: () => `${ws}/agents`, inbox: () => `${ws}/inbox`, - chat: () => `${ws}/chat`, myIssues: () => `${ws}/my-issues`, runtimes: () => `${ws}/runtimes`, skills: () => `${ws}/skills`, diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 162b27db0d..001fae3190 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -417,7 +417,7 @@ export function useRealtimeSync( qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() }); }); - // --- Chat / task events (global, survives chat page unmount) --- + // --- Chat / task events (global, survives ChatWindow unmount) --- // // Single source of truth: the Query cache. No Zustand writes here — the // earlier mirror caused a race where the cache and store disagreed diff --git a/packages/views/chat/components/chat-fab.tsx b/packages/views/chat/components/chat-fab.tsx new file mode 100644 index 0000000000..797fca6496 --- /dev/null +++ b/packages/views/chat/components/chat-fab.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { MessageCircle } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { cn } from "@multica/ui/lib/utils"; +import { useChatStore } from "@multica/core/chat"; +import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { createLogger } from "@multica/core/logger"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@multica/ui/components/ui/tooltip"; + +const logger = createLogger("chat.ui"); + +export function ChatFab() { + const wsId = useWorkspaceId(); + const isOpen = useChatStore((s) => s.isOpen); + const toggle = useChatStore((s) => s.toggle); + const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId)); + const { data: pending } = useQuery(pendingChatTasksOptions(wsId)); + + if (isOpen) return null; + + const unreadSessionCount = sessions.filter((s) => s.has_unread).length; + const isRunning = (pending?.tasks ?? []).length > 0; + + const handleClick = () => { + logger.info("fab.click (open chat)", { unreadSessionCount, isRunning }); + toggle(); + }; + + // Tooltip text communicates the state that isn't carried by the icon/badge. + const tooltip = isRunning + ? "Multica is working..." + : unreadSessionCount > 0 + ? `${unreadSessionCount} unread ${unreadSessionCount === 1 ? "chat" : "chats"}` + : "Ask Multica"; + + return ( + + + + {unreadSessionCount > 0 && ( + + {unreadSessionCount > 9 ? "9+" : unreadSessionCount} + + )} + + {tooltip} + + ); +} diff --git a/packages/views/chat/components/chat-input.tsx b/packages/views/chat/components/chat-input.tsx index 51745c900c..d5f9e5c0c1 100644 --- a/packages/views/chat/components/chat-input.tsx +++ b/packages/views/chat/components/chat-input.tsx @@ -81,7 +81,7 @@ export function ChatInput({ return (
-
+
{topSlot}
m.role === "assistant" && m.task_id === pendingTaskId, ); - const displayMessages: ChatMessage[] = pendingTaskId && !pendingAlreadyPersisted - ? [...messages, { - id: `pending-${pendingTaskId}`, - chat_session_id: messages[messages.length - 1]?.chat_session_id ?? "", - role: "assistant", - content: "", - task_id: pendingTaskId, - created_at: new Date().toISOString(), - }] - : messages; + + // Live timeline for the in-flight task. useRealtimeSync keeps this cache + // current via setQueryData on task:message events. + const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted; + const { data: liveTaskMessages } = useQuery({ + ...taskMessagesOptions(pendingTaskId ?? ""), + enabled: showLiveTimeline, + }); + const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem); + const hasLive = showLiveTimeline && liveTimeline.length > 0; return (
@@ -63,14 +60,15 @@ export function ChatMessageList({ * views doesn't jolt the reading width. px-5 is a touch tighter * than issue-detail's px-8 because the chat window can be narrow. */}
- {displayMessages.map((msg) => ( - + {messages.map((msg) => ( + ))} - {isWaiting && !pendingTaskId && ( + {hasLive && ( +
+ +
+ )} + {isWaiting && !hasLive && !pendingAlreadyPersisted && ( )}
@@ -118,7 +116,7 @@ function toTimelineItem(m: TaskMessagePayload): ChatTimelineItem { // ─── Message bubbles ───────────────────────────────────────────────────── -function MessageBubble({ message, isPending }: { message: ChatMessage; isPending?: boolean }) { +function MessageBubble({ message }: { message: ChatMessage }) { if (message.role === "user") { return (
@@ -135,15 +133,13 @@ function MessageBubble({ message, isPending }: { message: ChatMessage; isPending ); } - return ; + return ; } function AssistantMessage({ message, - isPending, }: { message: ChatMessage; - isPending?: boolean; }) { const taskId = message.task_id; @@ -161,13 +157,11 @@ function AssistantMessage({
{timeline.length > 0 ? ( - ) : message.content ? ( + ) : (
{message.content}
- ) : isPending ? ( - - ) : null} + )}
); } diff --git a/packages/views/chat/components/chat-page.tsx b/packages/views/chat/components/chat-page.tsx deleted file mode 100644 index e02d1550e7..0000000000 --- a/packages/views/chat/components/chat-page.tsx +++ /dev/null @@ -1,508 +0,0 @@ -"use client"; - -import React, { useCallback, useEffect, useMemo, useRef } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { History, Plus, Bot, ChevronDown, Check } from "lucide-react"; -import { cn } from "@multica/ui/lib/utils"; -import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar"; -import { Button } from "@multica/ui/components/ui/button"; -import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; -import { Popover, PopoverContent, PopoverTrigger } from "@multica/ui/components/ui/popover"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@multica/ui/components/ui/dropdown-menu"; -import { useWorkspaceId } from "@multica/core/hooks"; -import { useAuthStore } from "@multica/core/auth"; -import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries"; -import { canAssignAgent } from "@multica/views/issues/components"; -import { api } from "@multica/core/api"; -import { - chatSessionsOptions, - allChatSessionsOptions, - chatMessagesOptions, - pendingChatTaskOptions, - chatKeys, -} from "@multica/core/chat/queries"; -import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations"; -import { useChatStore } from "@multica/core/chat"; -import { PageHeader } from "../../layout/page-header"; -import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list"; -import { ChatInput } from "./chat-input"; -import { - ContextAnchorButton, - ContextAnchorCard, - buildAnchorMarkdown, - useRouteAnchorCandidate, -} from "./context-anchor"; -import { createLogger } from "@multica/core/logger"; -import type { Agent, ChatMessage, ChatSession } from "@multica/core/types"; - -const uiLogger = createLogger("chat.ui"); -const apiLogger = createLogger("chat.api"); - -export function ChatPage() { - const wsId = useWorkspaceId(); - const activeSessionId = useChatStore((s) => s.activeSessionId); - const selectedAgentId = useChatStore((s) => s.selectedAgentId); - const setActiveSession = useChatStore((s) => s.setActiveSession); - const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId); - const user = useAuthStore((s) => s.user); - const { data: agents = [] } = useQuery(agentListOptions(wsId)); - const { data: members = [] } = useQuery(memberListOptions(wsId)); - const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery( - chatSessionsOptions(wsId), - ); - const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId)); - const { data: rawMessages, isLoading: messagesLoading } = useQuery( - chatMessagesOptions(activeSessionId ?? ""), - ); - const messages = activeSessionId ? rawMessages ?? [] : []; - const showSkeleton = !!activeSessionId && messagesLoading; - - const { data: pendingTask } = useQuery( - pendingChatTaskOptions(activeSessionId ?? ""), - ); - const pendingTaskId = pendingTask?.task_id ?? null; - - const currentSession = activeSessionId - ? allSessions.find((s) => s.id === activeSessionId) - : null; - const isSessionArchived = currentSession?.status === "archived"; - - const qc = useQueryClient(); - const createSession = useCreateChatSession(); - const markRead = useMarkChatSessionRead(); - - const currentMember = members.find((m) => m.user_id === user?.id); - const memberRole = currentMember?.role; - const availableAgents = agents.filter( - (a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole), - ); - const activeAgent = - availableAgents.find((a) => a.id === selectedAgentId) ?? - availableAgents[0] ?? - null; - - // Restore most recent active session once the session query resolves. - // The ref is set only AFTER we've seen a successful query — setting it - // unconditionally on first render would lose the restore whenever the - // page mounts before the query returns (cold-start / direct navigate). - const didRestoreRef = useRef(false); - useEffect(() => { - if (didRestoreRef.current) return; - if (!sessionsLoaded) return; - didRestoreRef.current = true; - if (activeSessionId) return; - const latest = sessions.find((s) => s.status === "active"); - if (latest) setActiveSession(latest.id); - // eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load - }, [sessionsLoaded, sessions]); - - // Auto mark-as-read whenever the viewer is on a session with unread. - const currentHasUnread = - sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false; - useEffect(() => { - if (!activeSessionId || !currentHasUnread) return; - markRead.mutate(activeSessionId); - // eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable - }, [activeSessionId, currentHasUnread]); - - const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId); - - const handleSend = useCallback( - async (content: string) => { - if (!activeAgent) { - apiLogger.warn("sendChatMessage skipped: no active agent"); - return; - } - const focusOn = useChatStore.getState().focusMode; - const finalContent = focusOn && anchorCandidate - ? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}` - : content; - - let sessionId = activeSessionId; - const isNewSession = !sessionId; - - apiLogger.info("sendChatMessage.start", { - sessionId, - isNewSession, - agentId: activeAgent.id, - contentLength: finalContent.length, - }); - - if (!sessionId) { - const session = await createSession.mutateAsync({ - agent_id: activeAgent.id, - title: finalContent.slice(0, 50), - }); - sessionId = session.id; - setActiveSession(sessionId); - } - - const optimistic: ChatMessage = { - id: `optimistic-${Date.now()}`, - chat_session_id: sessionId, - role: "user", - content: finalContent, - task_id: null, - created_at: new Date().toISOString(), - }; - qc.setQueryData( - chatKeys.messages(sessionId), - (old) => (old ? [...old, optimistic] : [optimistic]), - ); - - const result = await api.sendChatMessage(sessionId, finalContent); - qc.setQueryData(chatKeys.pendingTask(sessionId), { - task_id: result.task_id, - status: "queued", - }); - qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); - }, - [activeSessionId, activeAgent, anchorCandidate, createSession, setActiveSession, qc], - ); - - const handleStop = useCallback(async () => { - if (!pendingTaskId) return; - try { - await api.cancelTaskById(pendingTaskId); - } catch (err) { - apiLogger.warn("cancelTask.error", { taskId: pendingTaskId, err }); - } - if (activeSessionId) { - qc.setQueryData(chatKeys.pendingTask(activeSessionId), {}); - qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) }); - } - }, [pendingTaskId, activeSessionId, qc]); - - const handleSelectAgent = useCallback( - (agent: Agent) => { - if (activeAgent && agent.id === activeAgent.id) return; - uiLogger.info("selectAgent", { from: selectedAgentId, to: agent.id }); - setSelectedAgentId(agent.id); - setActiveSession(null); - }, - [activeAgent, selectedAgentId, setSelectedAgentId, setActiveSession], - ); - - const handleNewChat = useCallback(() => { - setActiveSession(null); - }, [setActiveSession]); - - const handleSelectSession = useCallback( - (session: ChatSession) => { - if (activeAgent && session.agent_id !== activeAgent.id) { - setSelectedAgentId(session.agent_id); - } - setActiveSession(session.id); - }, - [activeAgent, setSelectedAgentId, setActiveSession], - ); - - const hasMessages = messages.length > 0 || !!pendingTaskId; - const activeTitle = currentSession?.title?.trim() || "New chat"; - - return ( -
- - {activeTitle} -
- - - - } - > - - - New chat - -
-
- - {/* Body — centered max-width column */} -
- {showSkeleton ? ( -
- -
- ) : hasMessages ? ( -
- -
- ) : ( - - )} - -
- } - leftAdornment={ - - } - rightAdornment={} - /> -
-
-
- ); -} - -/** - * Popover-based history list. Per product direction, session history lives - * inside the Chat tab — not in the global sidebar — so that Multica doesn't - * read as "just another chat app." The trigger is a History icon in the - * page header. - */ -function HistoryPopover({ - sessions, - agents, - activeSessionId, - onSelectSession, -}: { - sessions: ChatSession[]; - agents: Agent[]; - activeSessionId: string | null; - onSelectSession: (session: ChatSession) => void; -}) { - const [open, setOpen] = React.useState(false); - const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]); - - return ( - - - - } - /> - } - > - - - History - - -
- History -
-
- {sessions.length === 0 ? ( -
- No previous chats -
- ) : ( - sessions.map((session) => { - const isCurrent = session.id === activeSessionId; - const agent = agentById.get(session.agent_id) ?? null; - return ( - - ); - }) - )} -
-
-
- ); -} - -function AgentDropdown({ - agents, - activeAgent, - userId, - onSelect, -}: { - agents: Agent[]; - activeAgent: Agent | null; - userId: string | undefined; - onSelect: (agent: Agent) => void; -}) { - const { mine, others } = useMemo(() => { - const mine: Agent[] = []; - const others: Agent[] = []; - for (const a of agents) { - if (a.owner_id === userId) mine.push(a); - else others.push(a); - } - return { mine, others }; - }, [agents, userId]); - - if (!activeAgent) { - return No agents; - } - - return ( - - - - {activeAgent.name} - - - - {mine.length > 0 && ( - - My agents - {mine.map((agent) => ( - - ))} - - )} - {mine.length > 0 && others.length > 0 && } - {others.length > 0 && ( - - Others - {others.map((agent) => ( - - ))} - - )} - - - ); -} - -function AgentMenuItem({ - agent, - isCurrent, - onSelect, -}: { - agent: Agent; - isCurrent: boolean; - onSelect: (agent: Agent) => void; -}) { - return ( - onSelect(agent)} - className="flex min-w-0 items-center gap-2" - > - - {agent.name} - {isCurrent && } - - ); -} - -function AgentAvatarSmall({ agent }: { agent: Agent | null }) { - return ( - - {agent?.avatar_url && } - - - - - ); -} - -const STARTER_PROMPTS: { icon: string; text: string }[] = [ - { icon: "📋", text: "List my open tasks by priority" }, - { icon: "📝", text: "Summarize what I did today" }, - { icon: "💡", text: "Plan what to work on next" }, -]; - -function EmptyState({ - agentName, - onPickPrompt, -}: { - agentName?: string; - onPickPrompt: (text: string) => void; -}) { - return ( -
-
-

- {agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"} -

-

How can I help?

-
-
- {STARTER_PROMPTS.map((prompt) => ( - - ))} -
-
- ); -} diff --git a/packages/views/chat/components/chat-resize-handles.tsx b/packages/views/chat/components/chat-resize-handles.tsx new file mode 100644 index 0000000000..df67b60c9f --- /dev/null +++ b/packages/views/chat/components/chat-resize-handles.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; + +type DragDir = "left" | "top" | "corner"; + +interface ChatResizeHandlesProps { + onDragStart: (e: React.PointerEvent, dir: DragDir) => void; +} + +export function ChatResizeHandles({ onDragStart }: ChatResizeHandlesProps) { + return ( + <> + {/* Left edge — expands width when dragged left */} +
onDragStart(e, "left")} + className="absolute left-0 top-4 bottom-0 w-1 z-10 cursor-col-resize" + /> + {/* Top edge — expands height when dragged up */} +
onDragStart(e, "top")} + className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize" + /> + {/* Top-left corner — expands both width and height */} +
onDragStart(e, "corner")} + className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize" + /> + + ); +} diff --git a/packages/views/chat/components/chat-session-history.tsx b/packages/views/chat/components/chat-session-history.tsx new file mode 100644 index 0000000000..fe731785fc --- /dev/null +++ b/packages/views/chat/components/chat-session-history.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { ArrowLeft, MessageSquare, Bot } from "lucide-react"; +import { cn } from "@multica/ui/lib/utils"; +import { Button } from "@multica/ui/components/ui/button"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; +import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { agentListOptions } from "@multica/core/workspace/queries"; +import { allChatSessionsOptions } from "@multica/core/chat/queries"; +import { useChatStore } from "@multica/core/chat"; +import { createLogger } from "@multica/core/logger"; +import type { ChatSession, Agent } from "@multica/core/types"; + +const logger = createLogger("chat.ui"); + +export function ChatSessionHistory() { + const wsId = useWorkspaceId(); + const setShowHistory = useChatStore((s) => s.setShowHistory); + const setActiveSession = useChatStore((s) => s.setActiveSession); + const activeSessionId = useChatStore((s) => s.activeSessionId); + + const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + + const agentMap = new Map(agents.map((a) => [a.id, a])); + + const handleSelectSession = (session: ChatSession) => { + logger.info("selectSession", { + from: activeSessionId, + to: session.id, + agentId: session.agent_id, + status: session.status, + }); + // Changing activeSessionId flips the query keys for messages + + // pending-task; no manual clear needed. + setActiveSession(session.id); + setShowHistory(false); + }; + + return ( +
+ {/* Header */} +
+ + setShowHistory(false)} + /> + } + > + + + Back + + Chat History +
+ + {/* Session list */} +
+ {sessions.length === 0 ? ( +
+ + No chat sessions yet +
+ ) : ( +
+ {sessions.map((session) => ( + handleSelectSession(session)} + /> + ))} +
+ )} +
+
+ ); +} + +function SessionItem({ + session, + agent, + isActive, + onSelect, +}: { + session: ChatSession; + agent: Agent | null; + isActive: boolean; + onSelect: () => void; +}) { + const timeAgo = formatTimeAgo(session.updated_at); + + return ( + + ); +} + +function formatTimeAgo(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx new file mode 100644 index 0000000000..81ba095a09 --- /dev/null +++ b/packages/views/chat/components/chat-window.tsx @@ -0,0 +1,648 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar"; +import { Button } from "@multica/ui/components/ui/button"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@multica/ui/components/ui/dropdown-menu"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { useAuthStore } from "@multica/core/auth"; +import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries"; +import { canAssignAgent } from "@multica/views/issues/components"; +import { api } from "@multica/core/api"; +import { + chatSessionsOptions, + allChatSessionsOptions, + chatMessagesOptions, + pendingChatTaskOptions, + chatKeys, +} from "@multica/core/chat/queries"; +import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations"; +import { useChatStore } from "@multica/core/chat"; +import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list"; +import { ChatInput } from "./chat-input"; +import { + ContextAnchorButton, + ContextAnchorCard, + buildAnchorMarkdown, + useRouteAnchorCandidate, +} from "./context-anchor"; +import { ChatResizeHandles } from "./chat-resize-handles"; +import { useChatResize } from "./use-chat-resize"; +import { createLogger } from "@multica/core/logger"; +import type { Agent, ChatMessage, ChatSession } from "@multica/core/types"; + +const uiLogger = createLogger("chat.ui"); +const apiLogger = createLogger("chat.api"); + +export function ChatWindow() { + const wsId = useWorkspaceId(); + const isOpen = useChatStore((s) => s.isOpen); + const activeSessionId = useChatStore((s) => s.activeSessionId); + const selectedAgentId = useChatStore((s) => s.selectedAgentId); + const setOpen = useChatStore((s) => s.setOpen); + const setActiveSession = useChatStore((s) => s.setActiveSession); + const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId); + const user = useAuthStore((s) => s.user); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId)); + const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId)); + const { data: rawMessages, isLoading: messagesLoading } = useQuery( + chatMessagesOptions(activeSessionId ?? ""), + ); + // When no active session, always show empty — don't use stale cache + const messages = activeSessionId ? rawMessages ?? [] : []; + // Skeleton only shows for an un-cached session fetch. Cached switches + // return data synchronously — no flash. `enabled: false` (new chat) + // keeps isLoading false so the starter prompts aren't hidden. + const showSkeleton = !!activeSessionId && messagesLoading; + + // Server-authoritative pending task. Survives refresh / reopen / session + // switch because it's keyed on sessionId in the Query cache; WS events + // (chat:message / chat:done / task:*) keep it invalidated in real time. + // + // This is the SOLE source for pendingTaskId — no mirror in the store. + const { data: pendingTask } = useQuery( + pendingChatTaskOptions(activeSessionId ?? ""), + ); + const pendingTaskId = pendingTask?.task_id ?? null; + + // Check if current session is archived + const currentSession = activeSessionId + ? allSessions.find((s) => s.id === activeSessionId) + : null; + const isSessionArchived = currentSession?.status === "archived"; + + const qc = useQueryClient(); + const createSession = useCreateChatSession(); + const markRead = useMarkChatSessionRead(); + + const currentMember = members.find((m) => m.user_id === user?.id); + const memberRole = currentMember?.role; + const availableAgents = agents.filter( + (a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole), + ); + + // Resolve selected agent: stored preference → first available + const activeAgent = + availableAgents.find((a) => a.id === selectedAgentId) ?? + availableAgents[0] ?? + null; + + // Mount / unmount logging. ChatWindow lives in DashboardLayout, so this + // fires on layout mount (login / workspace switch / fresh page load). + useEffect(() => { + uiLogger.info("ChatWindow mount", { + isOpen, + activeSessionId, + pendingTaskId, + selectedAgentId, + wsId, + }); + return () => { + uiLogger.info("ChatWindow unmount", { + activeSessionId, + pendingTaskId, + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount + }, []); + + // Auto-restore most recent active session from server (only once on mount) + const didRestoreRef = useRef(false); + useEffect(() => { + if (didRestoreRef.current) return; + didRestoreRef.current = true; + if (activeSessionId || sessions.length === 0) { + uiLogger.debug("restore session skipped", { + reason: activeSessionId ? "already has session" : "no sessions", + activeSessionId, + sessionCount: sessions.length, + }); + return; + } + const latest = sessions.find((s) => s.status === "active"); + if (latest) { + uiLogger.info("restore session on mount", { sessionId: latest.id }); + setActiveSession(latest.id); + } else { + uiLogger.debug("restore session: no active session found"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load + }, [sessions]); + + // WS events are handled globally in useRealtimeSync — the query cache + // stays current even when this window is closed. See packages/core/realtime/. + + // Auto mark-as-read whenever the user is looking at a session with unread + // state: window open + a session active + has_unread → PATCH. + // has_unread comes from the list query; WS handlers invalidate it on + // chat:done so a reply arriving while the user watches triggers this + // effect again and is instantly cleared. + const currentHasUnread = + sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false; + useEffect(() => { + if (!isOpen || !activeSessionId) return; + if (!currentHasUnread) return; + uiLogger.info("auto markRead", { sessionId: activeSessionId }); + markRead.mutate(activeSessionId); + // eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable + }, [isOpen, activeSessionId, currentHasUnread]); + + // Focus-mode anchor: derived from route each render. Prepended to the + // outgoing message when focus is on; the anchor persists across sends + // (focus mode tracks the user's page, not a per-message attachment). + const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId); + + const handleSend = useCallback( + async (content: string) => { + if (!activeAgent) { + apiLogger.warn("sendChatMessage skipped: no active agent"); + return; + } + + const focusOn = useChatStore.getState().focusMode; + const finalContent = focusOn && anchorCandidate + ? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}` + : content; + + let sessionId = activeSessionId; + const isNewSession = !sessionId; + + apiLogger.info("sendChatMessage.start", { + sessionId, + isNewSession, + agentId: activeAgent.id, + contentLength: finalContent.length, + hasAnchor: focusOn && !!anchorCandidate, + }); + + if (!sessionId) { + const session = await createSession.mutateAsync({ + agent_id: activeAgent.id, + title: finalContent.slice(0, 50), + }); + sessionId = session.id; + setActiveSession(sessionId); + } + + // Optimistic: show user message immediately. + const optimistic: ChatMessage = { + id: `optimistic-${Date.now()}`, + chat_session_id: sessionId, + role: "user", + content: finalContent, + task_id: null, + created_at: new Date().toISOString(), + }; + qc.setQueryData( + chatKeys.messages(sessionId), + (old) => (old ? [...old, optimistic] : [optimistic]), + ); + apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id }); + + const result = await api.sendChatMessage(sessionId, finalContent); + apiLogger.info("sendChatMessage.success", { + sessionId, + messageId: result.message_id, + taskId: result.task_id, + }); + // Seed pending-task optimistically so the spinner shows instantly — + // the WS chat:message handler will invalidate + refetch to confirm. + qc.setQueryData(chatKeys.pendingTask(sessionId), { + task_id: result.task_id, + status: "queued", + }); + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + }, + [ + activeSessionId, + activeAgent, + anchorCandidate, + createSession, + setActiveSession, + qc, + ], + ); + + const handleStop = useCallback(async () => { + if (!pendingTaskId) { + apiLogger.debug("cancelTask skipped: no pending task"); + return; + } + apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId }); + try { + await api.cancelTaskById(pendingTaskId); + apiLogger.info("cancelTask.success", { taskId: pendingTaskId }); + } catch (err) { + // Task may already be completed + apiLogger.warn("cancelTask.error (task may have already finished)", { taskId: pendingTaskId, err }); + } + if (activeSessionId) { + // Clear pending immediately; WS task:cancelled will confirm. + qc.setQueryData(chatKeys.pendingTask(activeSessionId), {}); + qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) }); + } + }, [pendingTaskId, activeSessionId, qc]); + + const handleSelectAgent = useCallback( + (agent: Agent) => { + // No-op when clicking the already-active agent — don't clobber the + // current session just because the user closed the menu this way. + // Compare against activeAgent (what the UI shows), not selectedAgentId + // (which may be null / point to an archived agent on first load). + if (activeAgent && agent.id === activeAgent.id) return; + uiLogger.info("selectAgent", { + from: selectedAgentId, + to: agent.id, + previousSessionId: activeSessionId, + }); + setSelectedAgentId(agent.id); + // Reset session when switching agent + setActiveSession(null); + }, + [activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession], + ); + + const handleNewChat = useCallback(() => { + uiLogger.info("newChat", { + previousSessionId: activeSessionId, + previousPendingTask: pendingTaskId, + }); + setActiveSession(null); + }, [activeSessionId, pendingTaskId, setActiveSession]); + + const handleSelectSession = useCallback( + (session: ChatSession) => { + // Sessions are bound 1:1 to an agent — picking a session from a + // different agent implicitly switches the agent too. + if (activeAgent && session.agent_id !== activeAgent.id) { + uiLogger.info("selectSession (cross-agent)", { + from: activeAgent.id, + toAgent: session.agent_id, + toSession: session.id, + }); + setSelectedAgentId(session.agent_id); + } + setActiveSession(session.id); + }, + [activeAgent, setSelectedAgentId, setActiveSession], + ); + + const handleMinimize = useCallback(() => { + uiLogger.info("minimize (close)", { + activeSessionId, + pendingTaskId, + }); + setOpen(false); + }, [activeSessionId, pendingTaskId, setOpen]); + + const windowRef = useRef(null); + const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef); + + // Show the list (vs empty state) as soon as there's anything to display — + // a real message, or a pending task whose timeline will stream in. + const hasMessages = messages.length > 0 || !!pendingTaskId; + + const isVisible = isOpen && boundsReady; + + const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden"; + const containerStyle: React.CSSProperties = { + width: `${renderWidth}px`, + height: `${renderHeight}px`, + opacity: isVisible ? 1 : 0, + transform: isVisible ? "scale(1)" : "scale(0.95)", + transformOrigin: "bottom right", + pointerEvents: isOpen ? "auto" : "none", + transition: isDragging + ? "none" + : "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out", + }; + + return ( +
+ + {/* Header — ⊕ new + session dropdown | window tools */} +
+
+ + + } + > + + + New chat + + +
+
+ + + } + > + {isAtMax ? : } + + + {isAtMax ? "Restore" : "Expand"} + + + + + } + > + + + Minimize + +
+
+ + {/* Messages / skeleton / empty state */} + {showSkeleton ? ( + + ) : hasMessages ? ( + + ) : ( + handleSend(text)} + /> + )} + + {/* Input — disabled for archived sessions */} + } + leftAdornment={ + + } + rightAdornment={} + /> +
+ ); +} + +/** + * Agent dropdown: avatar trigger, lists all available agents. Selecting a + * different agent = switch agent + start a fresh chat (session=null). + * The current agent is marked with a check and not clickable. + */ +function AgentDropdown({ + agents, + activeAgent, + userId, + onSelect, +}: { + agents: Agent[]; + activeAgent: Agent | null; + userId: string | undefined; + onSelect: (agent: Agent) => void; +}) { + // Split into the user's own agents and everyone else so the menu groups + // them — matches the old AgentSelector layout. + const { mine, others } = useMemo(() => { + const mine: Agent[] = []; + const others: Agent[] = []; + for (const a of agents) { + if (a.owner_id === userId) mine.push(a); + else others.push(a); + } + return { mine, others }; + }, [agents, userId]); + + if (!activeAgent) { + return No agents; + } + + return ( + + + + {activeAgent.name} + + + + {mine.length > 0 && ( + + My agents + {mine.map((agent) => ( + + ))} + + )} + {mine.length > 0 && others.length > 0 && } + {others.length > 0 && ( + + Others + {others.map((agent) => ( + + ))} + + )} + + + ); +} + +function AgentMenuItem({ + agent, + isCurrent, + onSelect, +}: { + agent: Agent; + isCurrent: boolean; + onSelect: (agent: Agent) => void; +}) { + return ( + onSelect(agent)} + className="flex min-w-0 items-center gap-2" + > + + {agent.name} + {isCurrent && } + + ); +} + +/** + * Session dropdown: lists ALL sessions across agents. Each row carries the + * owning agent's avatar so the user can tell them apart. Selecting a + * session from a different agent implicitly switches the agent too + * (sessions are bound 1:1 to an agent). "New chat" lives in the header's + * ⊕ button, not inside this dropdown. + */ +function SessionDropdown({ + sessions, + agents, + activeSessionId, + onSelectSession, +}: { + sessions: ChatSession[]; + agents: Agent[]; + activeSessionId: string | null; + onSelectSession: (session: ChatSession) => void; +}) { + const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]); + const activeSession = sessions.find((s) => s.id === activeSessionId); + const title = activeSession?.title?.trim() || "New chat"; + const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null; + + return ( + + + {triggerAgent && } + {title} + + + + {sessions.length === 0 ? ( +
+ No previous chats +
+ ) : ( + sessions.map((session) => { + const isCurrent = session.id === activeSessionId; + const agent = agentById.get(session.agent_id) ?? null; + return ( + onSelectSession(session)} + className="flex min-w-0 items-center gap-2" + > + {agent ? ( + + ) : ( + + )} + + {session.title?.trim() || "New chat"} + + {session.has_unread && ( + + )} + {isCurrent && } + + ); + }) + )} +
+
+ ); +} + +function AgentAvatarSmall({ agent }: { agent: Agent }) { + return ( + + {agent.avatar_url && } + + + + + ); +} + +/** + * Three starter prompts shown on the empty state. Tapping one sends it + * immediately — ChatGPT-style — because the point is showing users what + * this chat is for: operating on the workspace, not open-ended Q&A. + */ +const STARTER_PROMPTS: { icon: string; text: string }[] = [ + { icon: "📋", text: "List my open tasks by priority" }, + { icon: "📝", text: "Summarize what I did today" }, + { icon: "💡", text: "Plan what to work on next" }, +]; + +function EmptyState({ + agentName, + onPickPrompt, +}: { + agentName?: string; + onPickPrompt: (text: string) => void; +}) { + return ( +
+
+

+ {agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"} +

+

Try asking

+
+
+ {STARTER_PROMPTS.map((prompt) => ( + + ))} +
+
+ ); +} diff --git a/packages/views/chat/components/context-anchor.tsx b/packages/views/chat/components/context-anchor.tsx index 23dc2974f1..27c4f39c93 100644 --- a/packages/views/chat/components/context-anchor.tsx +++ b/packages/views/chat/components/context-anchor.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { Focus } from "lucide-react"; import type { ContextAnchor } from "@multica/core/chat"; @@ -34,42 +33,11 @@ export function buildAnchorMarkdown(anchor: ContextAnchor): string { return `Context: Project "${anchor.label}"`; } -/** - * Returns true when the given pathname can resolve to an anchor candidate - * (issue detail, project detail, or inbox). Used by both the resolver and - * the tracker so they agree on which routes are anchor-eligible. - */ -function isAnchorEligiblePath(pathname: string): boolean { - if (/^\/[^/]+\/issues\/[^/]+$/.test(pathname)) return true; - if (/^\/[^/]+\/projects\/[^/]+$/.test(pathname)) return true; - if (/^\/[^/]+\/inbox$/.test(pathname)) return true; - return false; -} - -/** - * Runs an effect that remembers the last anchor-eligible location the user - * visited. Mount this in a component that's present on every page (the app - * sidebar) so the chat page — which is its own route and therefore has no - * anchor of its own — can still know what the user was just looking at. - */ -export function useAnchorTracker(): void { - const { pathname, searchParams } = useNavigation(); - const setLastAnchorLocation = useChatStore((s) => s.setLastAnchorLocation); - useEffect(() => { - if (!isAnchorEligiblePath(pathname)) return; - setLastAnchorLocation({ pathname, search: searchParams.toString() }); - }, [pathname, searchParams, setLastAnchorLocation]); -} - /** * Resolve the current page into an anchorable candidate, or null if the user * is somewhere without a natural focus object. Subscribes via react-query so * the result updates the instant the relevant cache fills. * - * When the user is on the Chat route (no intrinsic anchor), falls back to - * the last anchor-eligible location remembered by `useAnchorTracker`, so - * "open Chat from an issue → focus mode still attaches that issue" works. - * * `wsId` is passed in (per CLAUDE.md convention) so this hook works outside * a WorkspaceIdProvider if ever reused elsewhere. */ @@ -78,20 +46,10 @@ export function useRouteAnchorCandidate(wsId: string): { isResolving: boolean; } { const { pathname, searchParams } = useNavigation(); - const lastAnchorLocation = useChatStore((s) => s.lastAnchorLocation); - // On the Chat route there's no intrinsic anchor; substitute the last - // anchor-eligible location the user visited. Anywhere else, use the - // live route directly. - const useFallback = !isAnchorEligiblePath(pathname) && !!lastAnchorLocation; - const effectivePath = useFallback ? lastAnchorLocation!.pathname : pathname; - const effectiveSearch = useFallback - ? new URLSearchParams(lastAnchorLocation!.search) - : searchParams; - - const issueMatch = effectivePath.match(/^\/[^/]+\/issues\/([^/]+)$/); - const projectMatch = effectivePath.match(/^\/[^/]+\/projects\/([^/]+)$/); - const isInbox = /^\/[^/]+\/inbox$/.test(effectivePath); + const issueMatch = pathname.match(/^\/[^/]+\/issues\/([^/]+)$/); + const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/); + const isInbox = /^\/[^/]+\/inbox$/.test(pathname); const routeIssueId = issueMatch ? decodeURIComponent(issueMatch[1]!) : null; const routeProjectId = projectMatch @@ -103,7 +61,7 @@ export function useRouteAnchorCandidate(wsId: string): { ...inboxListOptions(wsId), enabled: isInbox, }); - const inboxKey = isInbox ? effectiveSearch.get("issue") : null; + const inboxKey = isInbox ? searchParams.get("issue") : null; const inboxSelectedIssueId = isInbox && inboxKey ? inboxItems.find((i) => (i.issue_id ?? i.id) === inboxKey)?.issue_id ?? diff --git a/packages/views/chat/components/use-chat-resize.ts b/packages/views/chat/components/use-chat-resize.ts new file mode 100644 index 0000000000..01a53ddeb7 --- /dev/null +++ b/packages/views/chat/components/use-chat-resize.ts @@ -0,0 +1,140 @@ +"use client"; + +import React, { useRef, useCallback, useState, useEffect } from "react"; +import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat"; + +type DragDir = "left" | "top" | "corner"; + +const MAX_RATIO = 0.9; +const FALLBACK_MAX_W = 800; +const FALLBACK_MAX_H = 700; + +function clamp(v: number, min: number, max: number) { + return Math.max(min, Math.min(max, v)); +} + +export function useChatResize( + windowRef: React.RefObject, +) { + const chatWidth = useChatStore((s) => s.chatWidth); + const chatHeight = useChatStore((s) => s.chatHeight); + const isExpanded = useChatStore((s) => s.isExpanded); + const setChatSize = useChatStore((s) => s.setChatSize); + const setExpanded = useChatStore((s) => s.setExpanded); + + // ── Container bounds via ResizeObserver ──────────────────────────────── + const boundsRef = useRef({ maxW: FALLBACK_MAX_W, maxH: FALLBACK_MAX_H }); + const [boundsReady, setBoundsReady] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [, setRevision] = useState(0); + + useEffect(() => { + const el = windowRef.current; + const parent = el?.parentElement; + if (!parent) return; + + const update = () => { + const maxW = Math.floor(parent.clientWidth * MAX_RATIO); + const maxH = Math.floor(parent.clientHeight * MAX_RATIO); + setBoundsReady(true); // idempotent once true + // Only trigger a re-render if the bounds actually changed. Without this + // guard, any spurious ResizeObserver notification (including sub-pixel + // layout jitter during mount) schedules a setState that feeds back into + // the observer, producing "Maximum update depth exceeded". + const prev = boundsRef.current; + if (prev.maxW === maxW && prev.maxH === maxH) return; + boundsRef.current = { maxW, maxH }; + setRevision((r) => r + 1); + }; + + // Measure immediately (parent is already in DOM at this point) + update(); + + const ro = new ResizeObserver(update); + ro.observe(parent); + return () => ro.disconnect(); + }, [windowRef]); + + // ── Derive rendered size ────────────────────────────────────────────── + const { maxW, maxH } = boundsRef.current; + + const renderWidth = isExpanded ? maxW : clamp(chatWidth, CHAT_MIN_W, maxW); + const renderHeight = isExpanded ? maxH : clamp(chatHeight, CHAT_MIN_H, maxH); + + // ── Expand / Restore ────────────────────────────────────────────────── + const isAtMax = renderWidth >= maxW && renderHeight >= maxH; + + const toggleExpand = useCallback(() => { + if (isExpanded || isAtMax) { + setChatSize(CHAT_MIN_W, CHAT_MIN_H); + } else { + setExpanded(true); + } + }, [isExpanded, isAtMax, setChatSize, setExpanded]); + + // ── Drag ────────────────────────────────────────────────────────────── + const dragRef = useRef<{ + startX: number; + startY: number; + startW: number; + startH: number; + dir: DragDir; + } | null>(null); + + const startDrag = useCallback( + (e: React.PointerEvent, dir: DragDir) => { + e.preventDefault(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + + dragRef.current = { + startX: e.clientX, + startY: e.clientY, + startW: renderWidth, + startH: renderHeight, + dir, + }; + setIsDragging(true); + + const onPointerMove = (ev: PointerEvent) => { + const d = dragRef.current; + if (!d) return; + + const { maxW: mw, maxH: mh } = boundsRef.current; + + const rawW = + dir === "left" || dir === "corner" + ? d.startW - (ev.clientX - d.startX) + : d.startW; + const rawH = + dir === "top" || dir === "corner" + ? d.startH - (ev.clientY - d.startY) + : d.startH; + + setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh)); + }; + + const onPointerUp = () => { + dragRef.current = null; + setIsDragging(false); + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + + const cursorMap: Record = { + left: "col-resize", + top: "row-resize", + corner: "nw-resize", + }; + document.body.style.cursor = cursorMap[dir]; + document.body.style.userSelect = "none"; + }, + [renderWidth, renderHeight, setChatSize], + ); + + return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag }; +} diff --git a/packages/views/chat/index.ts b/packages/views/chat/index.ts index c898be125c..04295ff1f8 100644 --- a/packages/views/chat/index.ts +++ b/packages/views/chat/index.ts @@ -1 +1,2 @@ -export { ChatPage } from "./components/chat-page"; +export { ChatFab } from "./components/chat-fab"; +export { ChatWindow } from "./components/chat-window"; diff --git a/packages/views/editor/utils/link-handler.ts b/packages/views/editor/utils/link-handler.ts index c98decc02f..240aef9209 100644 --- a/packages/views/editor/utils/link-handler.ts +++ b/packages/views/editor/utils/link-handler.ts @@ -24,7 +24,6 @@ const WORKSPACE_ROUTE_SEGMENTS = new Set([ "autopilots", "agents", "inbox", - "chat", "my-issues", "runtimes", "skills", diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 28220e8e47..d2f4750141 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -29,8 +29,6 @@ import { SquarePen, CircleUser, FolderKanban, - MessageSquare, - Loader2, X, Zap, } from "lucide-react"; @@ -67,8 +65,6 @@ import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/pat import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries"; -import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries"; -import { useAnchorTracker } from "../chat/components/context-anchor"; import { api } from "@multica/core/api"; import { useModalStore } from "@multica/core/modals"; import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks"; @@ -97,14 +93,12 @@ const EMPTY_PINS: PinnedItem[] = []; const EMPTY_WORKSPACES: Awaited> = []; const EMPTY_INVITATIONS: Awaited> = []; const EMPTY_INBOX: Awaited> = []; -const EMPTY_CHAT_SESSIONS: Awaited> = []; // Nav items reference WorkspacePaths method names so they can be resolved // against the current workspace slug at render time (see AppSidebar body). // Only parameterless paths are valid nav destinations. type NavKey = | "inbox" - | "chat" | "myIssues" | "issues" | "projects" @@ -116,7 +110,6 @@ type NavKey = const personalNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [ { key: "inbox", label: "Inbox", icon: Inbox }, - { key: "chat", label: "Chat", icon: MessageSquare }, { key: "myIssues", label: "My Issues", icon: CircleUser }, ]; @@ -333,22 +326,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } () => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length, [inboxItems], ); - const { data: chatSessions = EMPTY_CHAT_SESSIONS } = useQuery({ - ...chatSessionsOptions(wsId ?? ""), - enabled: !!wsId, - }); - const hasChatUnread = React.useMemo( - () => chatSessions.some((s) => s.has_unread), - [chatSessions], - ); - const { data: pendingChatTasks } = useQuery({ - ...pendingChatTasksOptions(wsId ?? ""), - enabled: !!wsId, - }); - const hasChatRunning = (pendingChatTasks?.tasks.length ?? 0) > 0; - // Track last anchor-eligible route so the Chat page (which is its own route) - // can still resolve focus-mode context from the page the user was just on. - useAnchorTracker(); const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId); const { data: pinnedItems = EMPTY_PINS } = useQuery({ ...pinListOptions(wsId ?? "", userId ?? ""), @@ -608,12 +585,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } {unreadCount > 99 ? "99+" : unreadCount} )} - {item.label === "Chat" && hasChatRunning && ( - - )} - {item.label === "Chat" && !hasChatRunning && hasChatUnread && ( - - )} ); diff --git a/packages/views/layout/dashboard-layout.tsx b/packages/views/layout/dashboard-layout.tsx index b4ce7d397b..9ab955e61c 100644 --- a/packages/views/layout/dashboard-layout.tsx +++ b/packages/views/layout/dashboard-layout.tsx @@ -8,7 +8,7 @@ import { DashboardGuard } from "./dashboard-guard"; interface DashboardLayoutProps { children: ReactNode; - /** Rendered inside SidebarInset — absolute-positioned overlays */ + /** Rendered inside SidebarInset (e.g. ChatWindow, ChatFab — absolute-positioned overlays) */ extra?: ReactNode; /** Rendered inside sidebar header as a search trigger */ searchSlot?: ReactNode;