mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 12:05:06 +02:00
State management - Pending task / live timeline are now Query-cache single source; Zustand mirror removed (fixes duplicate assistant render caused by the invalidate→refetch race window) - WS subscriptions moved from ChatWindow to global useRealtimeSync so pending state survives minimize and refresh - New GET /chat/sessions/:id/pending-task to recover live state on mount - Drafts persisted per-session (was per-workspace) Unread tracking - Migration 040: chat_session.unread_since (event-driven; old chats stay clean — no mass backfill) - POST /chat/sessions/:id/read clears unread; broadcasts chat:session_read so other devices sync - New GET /chat/pending-tasks aggregate for the FAB - ChatFab: brand-color impulse animation while running, brand-dot badge of unread session count - ChatWindow auto-marks read when user is viewing the session Header redesign - Two independent dropdowns: agent (avatar + name + My/Others grouping) at the input bottom-left; session (title + agent avatar) in the header - ⊕ new-chat button replaces the old + and history buttons - Session dropdown lists all sessions across agents with avatars - Empty state: 3 clickable starter prompts that send immediately - Mention link renderer falls through to default span on null — fixes @member/@agent/@all silently disappearing app-wide - User messages render through Markdown - Enter submits in chat input only (with IME guard + codeBlock skip); bubble menu hidden in chat Misc - Partial index on agent_task_queue for fast pending-task lookup - 2 new storage keys added to clearWorkspaceStorage - useMarkChatSessionRead has onError rollback - chat.* namespace logs across store, mutations, components, realtime Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
383 lines
12 KiB
Go
383 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimw "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
"github.com/multica-ai/multica/server/internal/events"
|
|
"github.com/multica-ai/multica/server/internal/handler"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
"github.com/multica-ai/multica/server/internal/realtime"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/storage"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
var defaultOrigins = []string{
|
|
"http://localhost:3000", // Next.js dev
|
|
"http://localhost:5173", // electron-vite dev
|
|
"http://localhost:5174", // electron-vite dev (fallback port)
|
|
}
|
|
|
|
func allowedOrigins() []string {
|
|
raw := strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS"))
|
|
if raw == "" {
|
|
raw = strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
|
|
}
|
|
if raw == "" {
|
|
return defaultOrigins
|
|
}
|
|
|
|
parts := strings.Split(raw, ",")
|
|
origins := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
origin := strings.TrimSpace(part)
|
|
if origin != "" {
|
|
origins = append(origins, origin)
|
|
}
|
|
}
|
|
if len(origins) == 0 {
|
|
return defaultOrigins
|
|
}
|
|
return origins
|
|
}
|
|
|
|
// NewRouter creates the fully-configured Chi router with all middleware and routes.
|
|
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
|
|
queries := db.New(pool)
|
|
emailSvc := service.NewEmailService()
|
|
|
|
// Initialize storage with S3 as primary, fallback to local
|
|
var store storage.Storage
|
|
s3 := storage.NewS3StorageFromEnv()
|
|
if s3 != nil {
|
|
store = s3
|
|
} else {
|
|
local := storage.NewLocalStorageFromEnv()
|
|
if local != nil {
|
|
store = local
|
|
}
|
|
}
|
|
|
|
cfSigner := auth.NewCloudFrontSignerFromEnv()
|
|
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
|
|
|
|
r := chi.NewRouter()
|
|
|
|
// Global middleware
|
|
r.Use(chimw.RequestID)
|
|
r.Use(middleware.RequestLogger)
|
|
r.Use(chimw.Recoverer)
|
|
r.Use(middleware.ContentSecurityPolicy)
|
|
origins := allowedOrigins()
|
|
|
|
// Share allowed origins with WebSocket origin checker.
|
|
realtime.SetAllowedOrigins(origins)
|
|
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: origins,
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID", "X-CSRF-Token"},
|
|
AllowCredentials: true,
|
|
MaxAge: 300,
|
|
}))
|
|
|
|
// Health check
|
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
|
|
// WebSocket
|
|
mc := &membershipChecker{queries: queries}
|
|
pr := &patResolver{queries: queries}
|
|
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
realtime.HandleWebSocket(hub, mc, pr, w, r)
|
|
})
|
|
|
|
// Local file serving (when using local storage)
|
|
if local, ok := store.(*storage.LocalStorage); ok {
|
|
r.Get("/uploads/*", func(w http.ResponseWriter, r *http.Request) {
|
|
file := strings.TrimPrefix(r.URL.Path, "/uploads/")
|
|
local.ServeFile(w, r, file)
|
|
})
|
|
}
|
|
|
|
// Auth (public)
|
|
r.Post("/auth/send-code", h.SendCode)
|
|
r.Post("/auth/verify-code", h.VerifyCode)
|
|
r.Post("/auth/google", h.GoogleLogin)
|
|
r.Post("/auth/logout", h.Logout)
|
|
|
|
// Daemon API routes (require daemon token or valid user token)
|
|
r.Route("/api/daemon", func(r chi.Router) {
|
|
r.Use(middleware.DaemonAuth(queries))
|
|
|
|
r.Post("/register", h.DaemonRegister)
|
|
r.Post("/deregister", h.DaemonDeregister)
|
|
r.Post("/heartbeat", h.DaemonHeartbeat)
|
|
|
|
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
|
|
r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime)
|
|
r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage)
|
|
r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult)
|
|
r.Post("/runtimes/{runtimeId}/update/{updateId}/result", h.ReportUpdateResult)
|
|
|
|
r.Get("/tasks/{taskId}/status", h.GetTaskStatus)
|
|
r.Post("/tasks/{taskId}/start", h.StartTask)
|
|
r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress)
|
|
r.Post("/tasks/{taskId}/complete", h.CompleteTask)
|
|
r.Post("/tasks/{taskId}/fail", h.FailTask)
|
|
r.Post("/tasks/{taskId}/usage", h.ReportTaskUsage)
|
|
r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages)
|
|
r.Get("/tasks/{taskId}/messages", h.ListTaskMessages)
|
|
|
|
r.Get("/issues/{issueId}/gc-check", h.GetIssueGCCheck)
|
|
})
|
|
|
|
// Protected API routes
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.Auth(queries))
|
|
r.Use(middleware.RefreshCloudFrontCookies(cfSigner))
|
|
|
|
// --- User-scoped routes (no workspace context required) ---
|
|
r.Get("/api/me", h.GetMe)
|
|
r.Patch("/api/me", h.UpdateMe)
|
|
r.Post("/api/cli-token", h.IssueCliToken)
|
|
r.Post("/api/upload-file", h.UploadFile)
|
|
|
|
r.Route("/api/workspaces", func(r chi.Router) {
|
|
r.Get("/", h.ListWorkspaces)
|
|
r.Post("/", h.CreateWorkspace)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
// Member-level access
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceMemberFromURL(queries, "id"))
|
|
r.Get("/", h.GetWorkspace)
|
|
r.Get("/members", h.ListMembersWithUser)
|
|
r.Post("/leave", h.LeaveWorkspace)
|
|
})
|
|
// Admin-level access
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner", "admin"))
|
|
r.Put("/", h.UpdateWorkspace)
|
|
r.Patch("/", h.UpdateWorkspace)
|
|
r.Post("/members", h.CreateMember)
|
|
r.Route("/members/{memberId}", func(r chi.Router) {
|
|
r.Patch("/", h.UpdateMember)
|
|
r.Delete("/", h.DeleteMember)
|
|
})
|
|
})
|
|
// Owner-only access
|
|
r.With(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner")).Delete("/", h.DeleteWorkspace)
|
|
})
|
|
})
|
|
|
|
r.Route("/api/tokens", func(r chi.Router) {
|
|
r.Get("/", h.ListPersonalAccessTokens)
|
|
r.Post("/", h.CreatePersonalAccessToken)
|
|
r.Delete("/{id}", h.RevokePersonalAccessToken)
|
|
})
|
|
|
|
// --- Workspace-scoped routes (all require workspace membership) ---
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(middleware.RequireWorkspaceMember(queries))
|
|
|
|
// Assignee frequency
|
|
r.Get("/api/assignee-frequency", h.GetAssigneeFrequency)
|
|
|
|
// Issues
|
|
r.Route("/api/issues", func(r chi.Router) {
|
|
r.Get("/search", h.SearchIssues)
|
|
r.Get("/child-progress", h.ChildIssueProgress)
|
|
r.Get("/", h.ListIssues)
|
|
r.Post("/", h.CreateIssue)
|
|
r.Post("/batch-update", h.BatchUpdateIssues)
|
|
r.Post("/batch-delete", h.BatchDeleteIssues)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetIssue)
|
|
r.Put("/", h.UpdateIssue)
|
|
r.Delete("/", h.DeleteIssue)
|
|
r.Post("/comments", h.CreateComment)
|
|
r.Get("/comments", h.ListComments)
|
|
r.Get("/timeline", h.ListTimeline)
|
|
r.Get("/subscribers", h.ListIssueSubscribers)
|
|
r.Post("/subscribe", h.SubscribeToIssue)
|
|
r.Post("/unsubscribe", h.UnsubscribeFromIssue)
|
|
r.Get("/active-task", h.GetActiveTaskForIssue)
|
|
r.Post("/tasks/{taskId}/cancel", h.CancelTask)
|
|
r.Get("/task-runs", h.ListTasksByIssue)
|
|
r.Get("/usage", h.GetIssueUsage)
|
|
r.Post("/reactions", h.AddIssueReaction)
|
|
r.Delete("/reactions", h.RemoveIssueReaction)
|
|
r.Get("/attachments", h.ListAttachments)
|
|
r.Get("/children", h.ListChildIssues)
|
|
})
|
|
})
|
|
|
|
// Task messages (user-facing, not daemon auth)
|
|
r.Get("/api/tasks/{taskId}/messages", h.ListTaskMessagesByUser)
|
|
|
|
// Projects
|
|
r.Route("/api/projects", func(r chi.Router) {
|
|
r.Get("/search", h.SearchProjects)
|
|
r.Get("/", h.ListProjects)
|
|
r.Post("/", h.CreateProject)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetProject)
|
|
r.Put("/", h.UpdateProject)
|
|
r.Delete("/", h.DeleteProject)
|
|
})
|
|
})
|
|
|
|
// Pins
|
|
r.Route("/api/pins", func(r chi.Router) {
|
|
r.Get("/", h.ListPins)
|
|
r.Post("/", h.CreatePin)
|
|
r.Put("/reorder", h.ReorderPins)
|
|
r.Delete("/{itemType}/{itemId}", h.DeletePin)
|
|
})
|
|
|
|
// Attachments
|
|
r.Get("/api/attachments/{id}", h.GetAttachmentByID)
|
|
r.Delete("/api/attachments/{id}", h.DeleteAttachment)
|
|
|
|
// Comments
|
|
r.Route("/api/comments/{commentId}", func(r chi.Router) {
|
|
r.Put("/", h.UpdateComment)
|
|
r.Delete("/", h.DeleteComment)
|
|
r.Post("/reactions", h.AddReaction)
|
|
r.Delete("/reactions", h.RemoveReaction)
|
|
})
|
|
|
|
// Agents
|
|
r.Route("/api/agents", func(r chi.Router) {
|
|
r.Get("/", h.ListAgents)
|
|
r.With(middleware.RequireWorkspaceRole(queries, "owner", "admin")).Post("/", h.CreateAgent)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetAgent)
|
|
r.Put("/", h.UpdateAgent)
|
|
r.Post("/archive", h.ArchiveAgent)
|
|
r.Post("/restore", h.RestoreAgent)
|
|
r.Get("/tasks", h.ListAgentTasks)
|
|
r.Get("/skills", h.ListAgentSkills)
|
|
r.Put("/skills", h.SetAgentSkills)
|
|
})
|
|
})
|
|
|
|
// Skills
|
|
r.Route("/api/skills", func(r chi.Router) {
|
|
r.Get("/", h.ListSkills)
|
|
r.With(middleware.RequireWorkspaceRole(queries, "owner", "admin")).Post("/", h.CreateSkill)
|
|
r.With(middleware.RequireWorkspaceRole(queries, "owner", "admin")).Post("/import", h.ImportSkill)
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", h.GetSkill)
|
|
r.Put("/", h.UpdateSkill)
|
|
r.Delete("/", h.DeleteSkill)
|
|
r.Get("/files", h.ListSkillFiles)
|
|
r.Put("/files", h.UpsertSkillFile)
|
|
r.Delete("/files/{fileId}", h.DeleteSkillFile)
|
|
})
|
|
})
|
|
|
|
// Usage
|
|
r.Route("/api/usage", func(r chi.Router) {
|
|
r.Get("/daily", h.GetWorkspaceUsageByDay)
|
|
r.Get("/summary", h.GetWorkspaceUsageSummary)
|
|
})
|
|
|
|
// Runtimes
|
|
r.Route("/api/runtimes", func(r chi.Router) {
|
|
r.Get("/", h.ListAgentRuntimes)
|
|
r.Route("/{runtimeId}", func(r chi.Router) {
|
|
r.Get("/usage", h.GetRuntimeUsage)
|
|
r.Get("/activity", h.GetRuntimeTaskActivity)
|
|
r.Post("/ping", h.InitiatePing)
|
|
r.Get("/ping/{pingId}", h.GetPing)
|
|
r.Post("/update", h.InitiateUpdate)
|
|
r.Get("/update/{updateId}", h.GetUpdate)
|
|
r.Delete("/", h.DeleteAgentRuntime)
|
|
})
|
|
})
|
|
|
|
// Tasks (user-facing, with ownership check)
|
|
r.Post("/api/tasks/{taskId}/cancel", h.CancelTaskByUser)
|
|
|
|
r.Route("/api/chat/sessions", func(r chi.Router) {
|
|
r.Post("/", h.CreateChatSession)
|
|
r.Get("/", h.ListChatSessions)
|
|
r.Route("/{sessionId}", func(r chi.Router) {
|
|
r.Get("/", h.GetChatSession)
|
|
r.Delete("/", h.ArchiveChatSession)
|
|
r.Post("/messages", h.SendChatMessage)
|
|
r.Get("/messages", h.ListChatMessages)
|
|
r.Get("/pending-task", h.GetPendingChatTask)
|
|
r.Post("/read", h.MarkChatSessionRead)
|
|
})
|
|
})
|
|
r.Get("/api/chat/pending-tasks", h.ListPendingChatTasks)
|
|
|
|
// Inbox
|
|
r.Route("/api/inbox", func(r chi.Router) {
|
|
r.Get("/", h.ListInbox)
|
|
r.Get("/unread-count", h.CountUnreadInbox)
|
|
r.Post("/mark-all-read", h.MarkAllInboxRead)
|
|
r.Post("/archive-all", h.ArchiveAllInbox)
|
|
r.Post("/archive-all-read", h.ArchiveAllReadInbox)
|
|
r.Post("/archive-completed", h.ArchiveCompletedInbox)
|
|
r.Post("/{id}/read", h.MarkInboxRead)
|
|
r.Post("/{id}/archive", h.ArchiveInboxItem)
|
|
})
|
|
})
|
|
})
|
|
|
|
return r
|
|
}
|
|
|
|
// membershipChecker implements realtime.MembershipChecker using database queries.
|
|
type membershipChecker struct {
|
|
queries *db.Queries
|
|
}
|
|
|
|
func (mc *membershipChecker) IsMember(ctx context.Context, userID, workspaceID string) bool {
|
|
_, err := mc.queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: parseUUID(userID),
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
})
|
|
return err == nil
|
|
}
|
|
|
|
// patResolver implements realtime.PATResolver using database queries.
|
|
type patResolver struct {
|
|
queries *db.Queries
|
|
}
|
|
|
|
func (pr *patResolver) ResolveToken(ctx context.Context, token string) (string, bool) {
|
|
hash := auth.HashToken(token)
|
|
pat, err := pr.queries.GetPersonalAccessTokenByHash(ctx, hash)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
// Best-effort: update last_used_at
|
|
go pr.queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID)
|
|
return util.UUIDToString(pat.UserID), true
|
|
}
|
|
|
|
func parseUUID(s string) pgtype.UUID {
|
|
var u pgtype.UUID
|
|
if err := u.Scan(s); err != nil {
|
|
return pgtype.UUID{}
|
|
}
|
|
return u
|
|
}
|