diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index daa81a68..8797a35b 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -105,94 +105,10 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Group(func(r chi.Router) { r.Use(middleware.Auth(queries)) - // Auth + // --- User-scoped routes (no workspace context required) --- r.Get("/api/me", h.GetMe) r.Patch("/api/me", h.UpdateMe) - // Issues - r.Route("/api/issues", func(r chi.Router) { - r.With(middleware.RequireWorkspaceMember(queries)).Get("/", h.ListIssues) - r.With(middleware.RequireWorkspaceMember(queries)).Post("/", h.CreateIssue) - r.With(middleware.RequireWorkspaceMember(queries)).Post("/batch-update", h.BatchUpdateIssues) - r.With(middleware.RequireWorkspaceMember(queries)).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) - }) - }) - - // Comments - r.Route("/api/comments/{commentId}", func(r chi.Router) { - r.Put("/", h.UpdateComment) - r.Delete("/", h.DeleteComment) - }) - - // Agents - r.Route("/api/agents", func(r chi.Router) { - r.With(middleware.RequireWorkspaceMember(queries)).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.Delete("/", h.DeleteAgent) - 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.With(middleware.RequireWorkspaceMember(queries)).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) - }) - }) - - r.Route("/api/runtimes", func(r chi.Router) { - r.With(middleware.RequireWorkspaceMember(queries)).Get("/", h.ListAgentRuntimes) - r.Get("/{runtimeId}/usage", h.GetRuntimeUsage) - r.Get("/{runtimeId}/activity", h.GetRuntimeTaskActivity) - r.Post("/{runtimeId}/ping", h.InitiatePing) - r.Get("/{runtimeId}/ping/{pingId}", h.GetPing) - }) - - r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession) - - // Personal Access Tokens - r.Route("/api/tokens", func(r chi.Router) { - r.Get("/", h.ListPersonalAccessTokens) - r.Post("/", h.CreatePersonalAccessToken) - r.Delete("/{id}", h.RevokePersonalAccessToken) - }) - - // 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) - }) - - // Workspaces r.Route("/api/workspaces", func(r chi.Router) { r.Get("/", h.ListWorkspaces) r.Post("/", h.CreateWorkspace) @@ -219,6 +135,94 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route 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) + }) + + r.Post("/api/daemon/pairing-sessions/{token}/approve", h.ApproveDaemonPairingSession) + + // --- Workspace-scoped routes (all require workspace membership) --- + r.Group(func(r chi.Router) { + r.Use(middleware.RequireWorkspaceMember(queries)) + + // Issues + r.Route("/api/issues", func(r chi.Router) { + 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) + }) + }) + + // Comments + r.Route("/api/comments/{commentId}", func(r chi.Router) { + r.Put("/", h.UpdateComment) + r.Delete("/", h.DeleteComment) + }) + + // 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.Delete("/", h.DeleteAgent) + 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) + }) + }) + + // Runtimes + r.Route("/api/runtimes", func(r chi.Router) { + r.Get("/", h.ListAgentRuntimes) + r.Get("/{runtimeId}/usage", h.GetRuntimeUsage) + r.Get("/{runtimeId}/activity", h.GetRuntimeTaskActivity) + r.Post("/{runtimeId}/ping", h.InitiatePing) + r.Get("/{runtimeId}/ping/{pingId}", h.GetPing) + }) + + // 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 diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 6ab322e9..cb07e55f 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -706,7 +706,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo "MULTICA_TOKEN": d.client.Token(), "MULTICA_SERVER_URL": d.cfg.ServerBaseURL, "MULTICA_DAEMON_PORT": fmt.Sprintf("%d", d.cfg.HealthPort), - "MULTICA_WORKSPACE_ID": d.workspaceIDForRuntime(task.RuntimeID), + "MULTICA_WORKSPACE_ID": task.WorkspaceID, "MULTICA_AGENT_NAME": agentName, "MULTICA_AGENT_ID": task.AgentID, "MULTICA_TASK_ID": task.ID, @@ -805,20 +805,6 @@ func repoDataToInfo(repos []RepoData) []repocache.RepoInfo { return info } -// workspaceIDForRuntime returns the workspace ID that a runtime belongs to. -func (d *Daemon) workspaceIDForRuntime(runtimeID string) string { - d.mu.Lock() - defer d.mu.Unlock() - for _, ws := range d.workspaces { - for _, rid := range ws.runtimeIDs { - if rid == runtimeID { - return ws.workspaceID - } - } - } - return "" -} - func convertReposForEnv(repos []RepoData) []execenv.RepoContextForEnv { if len(repos) == 0 { return nil diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index f60a48df..39f8cee5 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -27,6 +27,7 @@ type Task struct { AgentID string `json:"agent_id"` RuntimeID string `json:"runtime_id"` IssueID string `json:"issue_id"` + WorkspaceID string `json:"workspace_id"` Agent *AgentData `json:"agent,omitempty"` Repos []RepoData `json:"repos,omitempty"` PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue diff --git a/server/internal/handler/activity.go b/server/internal/handler/activity.go index b7648834..bcb05886 100644 --- a/server/internal/handler/activity.go +++ b/server/internal/handler/activity.go @@ -49,7 +49,10 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { return } - comments, err := h.Queries.ListComments(r.Context(), issue.ID) + comments, err := h.Queries.ListComments(r.Context(), db.ListCommentsParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list comments") return diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index d2680a24..64ef2fae 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -93,6 +93,7 @@ type AgentTaskResponse struct { AgentID string `json:"agent_id"` RuntimeID string `json:"runtime_id"` IssueID string `json:"issue_id"` + WorkspaceID string `json:"workspace_id"` Status string `json:"status"` Priority int32 `json:"priority"` DispatchedAt *string `json:"dispatched_at"` @@ -303,7 +304,8 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { } resp := agentToResponse(agent) - h.publish(protocol.EventAgentCreated, workspaceID, "member", ownerID, map[string]any{"agent": resp}) + actorType, actorID := h.resolveActor(r, ownerID, workspaceID) + h.publish(protocol.EventAgentCreated, workspaceID, actorType, actorID, map[string]any{"agent": resp}) writeJSON(w, http.StatusCreated, resp) } @@ -398,7 +400,8 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) { resp := agentToResponse(agent) slog.Info("agent updated", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", uuidToString(agent.WorkspaceID))...) userID := requestUserID(r) - h.publish(protocol.EventAgentStatus, uuidToString(agent.WorkspaceID), "member", userID, map[string]any{"agent": resp}) + actorType, actorID := h.resolveActor(r, userID, uuidToString(agent.WorkspaceID)) + h.publish(protocol.EventAgentStatus, uuidToString(agent.WorkspaceID), actorType, actorID, map[string]any{"agent": resp}) writeJSON(w, http.StatusOK, resp) } @@ -424,7 +427,8 @@ func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) { slog.Info("agent deleted", append(logger.RequestAttrs(r), "agent_id", id, "workspace_id", wsID)...) userID := requestUserID(r) - h.publish(protocol.EventAgentDeleted, wsID, "member", userID, map[string]any{"agent_id": id, "workspace_id": wsID}) + actorType, actorID := h.resolveActor(r, userID, wsID) + h.publish(protocol.EventAgentDeleted, wsID, actorType, actorID, map[string]any{"agent_id": id, "workspace_id": wsID}) w.WriteHeader(http.StatusNoContent) } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 4c1a512f..4d46c0ef 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -45,7 +45,10 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) { return } - comments, err := h.Queries.ListComments(r.Context(), issue.ID) + comments, err := h.Queries.ListComments(r.Context(), db.ListCommentsParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list comments") return @@ -105,12 +108,13 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID)) comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{ - IssueID: issue.ID, - AuthorType: authorType, - AuthorID: parseUUID(authorID), - Content: req.Content, - Type: req.Type, - ParentID: parentID, + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + AuthorType: authorType, + AuthorID: parseUUID(authorID), + Content: req.Content, + Type: req.Type, + ParentID: parentID, }) if err != nil { slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...) @@ -147,26 +151,24 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { return } - // Load comment to check ownership - existing, err := h.Queries.GetComment(r.Context(), parseUUID(commentId)) + // Load comment scoped to current workspace. + workspaceID := resolveWorkspaceID(r) + existing, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ + ID: parseUUID(commentId), + WorkspaceID: parseUUID(workspaceID), + }) if err != nil { writeError(w, http.StatusNotFound, "comment not found") return } - // Load issue to get workspace - issue, err := h.Queries.GetIssue(r.Context(), existing.IssueID) - if err != nil { - writeError(w, http.StatusNotFound, "comment not found") - return - } - - member, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "comment not found") + member, ok := h.workspaceMember(w, r, workspaceID) if !ok { return } - isAuthor := existing.AuthorType == "member" && uuidToString(existing.AuthorID) == userID + actorType, actorID := h.resolveActor(r, userID, workspaceID) + isAuthor := existing.AuthorType == actorType && uuidToString(existing.AuthorID) == actorID isAdmin := roleAllowed(member.Role, "owner", "admin") if !isAuthor && !isAdmin { writeError(w, http.StatusForbidden, "only comment author or admin can edit") @@ -196,9 +198,8 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { } resp := commentToResponse(comment) - actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID)) slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...) - h.publish(protocol.EventCommentUpdated, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{"comment": resp}) + h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp}) writeJSON(w, http.StatusOK, resp) } @@ -210,26 +211,24 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) { return } - // Get the comment first to know the issue_id for the broadcast - comment, err := h.Queries.GetComment(r.Context(), parseUUID(commentId)) + // Load comment scoped to current workspace. + workspaceID := resolveWorkspaceID(r) + comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ + ID: parseUUID(commentId), + WorkspaceID: parseUUID(workspaceID), + }) if err != nil { writeError(w, http.StatusNotFound, "comment not found") return } - // Load issue to get workspace - issue, err := h.Queries.GetIssue(r.Context(), comment.IssueID) - if err != nil { - writeError(w, http.StatusNotFound, "comment not found") - return - } - - member, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "comment not found") + member, ok := h.workspaceMember(w, r, workspaceID) if !ok { return } - isAuthor := comment.AuthorType == "member" && uuidToString(comment.AuthorID) == userID + actorType, actorID := h.resolveActor(r, userID, workspaceID) + isAuthor := comment.AuthorType == actorType && uuidToString(comment.AuthorID) == actorID isAdmin := roleAllowed(member.Role, "owner", "admin") if !isAuthor && !isAdmin { writeError(w, http.StatusForbidden, "only comment author or admin can delete") @@ -241,10 +240,8 @@ func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to delete comment") return } - - actorType, actorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID)) slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...) - h.publish(protocol.EventCommentDeleted, uuidToString(issue.WorkspaceID), actorType, actorID, map[string]any{ + h.publish(protocol.EventCommentDeleted, workspaceID, actorType, actorID, map[string]any{ "comment_id": commentId, "issue_id": uuidToString(comment.IssueID), }) diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 32e371d6..30eb5d32 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -227,8 +227,9 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { } } - // Include workspace repos so the daemon can set up worktrees. + // Include workspace ID and repos so the daemon can set up worktrees. if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil { + resp.WorkspaceID = uuidToString(issue.WorkspaceID) if ws, err := h.Queries.GetWorkspace(r.Context(), issue.WorkspaceID); err == nil && ws.Repos != nil { var repos []RepoData if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 { diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index c3295c43..d8086a79 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -243,24 +243,25 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue return db.Issue{}, false } + workspaceID := resolveWorkspaceID(r) + if workspaceID == "" { + writeError(w, http.StatusBadRequest, "workspace_id is required") + return db.Issue{}, false + } + // Try identifier format first (e.g., "JIA-42"). - if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, resolveWorkspaceID(r)); ok { - if _, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "issue not found"); !ok { - return db.Issue{}, false - } + if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, workspaceID); ok { return issue, true } - issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID)) + issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ + ID: parseUUID(issueID), + WorkspaceID: parseUUID(workspaceID), + }) if err != nil { writeError(w, http.StatusNotFound, "issue not found") return db.Issue{}, false } - - if _, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "issue not found"); !ok { - return db.Issue{}, false - } - return issue, true } @@ -332,16 +333,20 @@ func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agent return db.Agent{}, false } - agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID)) + workspaceID := resolveWorkspaceID(r) + if workspaceID == "" { + writeError(w, http.StatusBadRequest, "workspace_id is required") + return db.Agent{}, false + } + + agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{ + ID: parseUUID(agentID), + WorkspaceID: parseUUID(workspaceID), + }) if err != nil { writeError(w, http.StatusNotFound, "agent not found") return db.Agent{}, false } - - if _, ok := h.requireWorkspaceMember(w, r, uuidToString(agent.WorkspaceID), "agent not found"); !ok { - return db.Agent{}, false - } - return agent, true } @@ -351,7 +356,16 @@ func (h *Handler) loadInboxItemForUser(w http.ResponseWriter, r *http.Request, i return db.InboxItem{}, false } - item, err := h.Queries.GetInboxItem(r.Context(), parseUUID(itemID)) + workspaceID := resolveWorkspaceID(r) + if workspaceID == "" { + writeError(w, http.StatusBadRequest, "workspace_id is required") + return db.InboxItem{}, false + } + + item, err := h.Queries.GetInboxItemInWorkspace(r.Context(), db.GetInboxItemInWorkspaceParams{ + ID: parseUUID(itemID), + WorkspaceID: parseUUID(workspaceID), + }) if err != nil { writeError(w, http.StatusNotFound, "inbox item not found") return db.InboxItem{}, false @@ -361,6 +375,5 @@ func (h *Handler) loadInboxItemForUser(w http.ResponseWriter, r *http.Request, i writeError(w, http.StatusNotFound, "inbox item not found") return db.InboxItem{}, false } - return item, true } diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index b2660bc8..0e611999 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -521,16 +521,16 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { json.Unmarshal(raw, &rawUpdates) } + workspaceID := resolveWorkspaceID(r) updated := 0 for _, issueID := range req.IssueIDs { - prevIssue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID)) + prevIssue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ + ID: parseUUID(issueID), + WorkspaceID: parseUUID(workspaceID), + }) if err != nil { continue } - workspaceID := uuidToString(prevIssue.WorkspaceID) - if _, ok := h.requireWorkspaceMember(w, r, workspaceID, ""); !ok { - continue - } params := db.UpdateIssueParams{ ID: prevIssue.ID, @@ -637,16 +637,16 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) { return } + workspaceID := resolveWorkspaceID(r) deleted := 0 for _, issueID := range req.IssueIDs { - issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID)) + issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ + ID: parseUUID(issueID), + WorkspaceID: parseUUID(workspaceID), + }) if err != nil { continue } - workspaceID := uuidToString(issue.WorkspaceID) - if _, ok := h.requireWorkspaceMember(w, r, workspaceID, ""); !ok { - continue - } h.TaskService.CancelTasksForIssue(r.Context(), issue.ID) diff --git a/server/internal/handler/skill.go b/server/internal/handler/skill.go index d6995659..f04510b6 100644 --- a/server/internal/handler/skill.go +++ b/server/internal/handler/skill.go @@ -123,16 +123,18 @@ func validateFilePath(p string) bool { } func (h *Handler) loadSkillForUser(w http.ResponseWriter, r *http.Request, id string) (db.Skill, bool) { - skill, err := h.Queries.GetSkill(r.Context(), parseUUID(id)) - if err != nil { - if isNotFound(err) { - writeError(w, http.StatusNotFound, "skill not found") - } else { - writeError(w, http.StatusInternalServerError, "failed to load skill") - } - return skill, false + workspaceID := resolveWorkspaceID(r) + if workspaceID == "" { + writeError(w, http.StatusBadRequest, "workspace_id is required") + return db.Skill{}, false } - if _, ok := h.requireWorkspaceMember(w, r, uuidToString(skill.WorkspaceID), "skill not found"); !ok { + + skill, err := h.Queries.GetSkillInWorkspace(r.Context(), db.GetSkillInWorkspaceParams{ + ID: parseUUID(id), + WorkspaceID: parseUUID(workspaceID), + }) + if err != nil { + writeError(w, http.StatusNotFound, "skill not found") return skill, false } return skill, true @@ -261,7 +263,8 @@ func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) { SkillResponse: skillToResponse(skill), Files: fileResps, } - h.publish(protocol.EventSkillCreated, workspaceID, "member", creatorID, map[string]any{"skill": resp}) + actorType, actorID := h.resolveActor(r, creatorID, workspaceID) + h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": resp}) writeJSON(w, http.StatusCreated, resp) } @@ -361,7 +364,9 @@ func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) { SkillResponse: skillToResponse(skill), Files: fileResps, } - h.publish(protocol.EventSkillUpdated, resolveWorkspaceID(r), "member", requestUserID(r), map[string]any{"skill": resp}) + wsID := resolveWorkspaceID(r) + actorType, actorID := h.resolveActor(r, requestUserID(r), wsID) + h.publish(protocol.EventSkillUpdated, wsID, actorType, actorID, map[string]any{"skill": resp}) writeJSON(w, http.StatusOK, resp) } @@ -379,7 +384,8 @@ func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to delete skill") return } - h.publish(protocol.EventSkillDeleted, uuidToString(skill.WorkspaceID), "member", requestUserID(r), map[string]any{"skill_id": id}) + 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}) w.WriteHeader(http.StatusNoContent) } @@ -846,7 +852,8 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) { SkillResponse: skillToResponse(skill), Files: fileResps, } - h.publish(protocol.EventSkillCreated, workspaceID, "member", creatorID, map[string]any{"skill": resp}) + actorType, actorID := h.resolveActor(r, creatorID, workspaceID) + h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": resp}) writeJSON(w, http.StatusCreated, resp) } @@ -1002,6 +1009,7 @@ func (h *Handler) SetAgentSkills(w http.ResponseWriter, r *http.Request) { for i, s := range skills { resp[i] = skillToResponse(s) } - h.publish(protocol.EventAgentStatus, uuidToString(agent.WorkspaceID), "member", requestUserID(r), map[string]any{"agent_id": uuidToString(agent.ID), "skills": resp}) + actorType, actorID := h.resolveActor(r, requestUserID(r), uuidToString(agent.WorkspaceID)) + h.publish(protocol.EventAgentStatus, uuidToString(agent.WorkspaceID), actorType, actorID, map[string]any{"agent_id": uuidToString(agent.ID), "skills": resp}) writeJSON(w, http.StatusOK, resp) } diff --git a/server/internal/handler/subscriber.go b/server/internal/handler/subscriber.go index f0904f9f..51fac323 100644 --- a/server/internal/handler/subscriber.go +++ b/server/internal/handler/subscriber.go @@ -89,7 +89,8 @@ func (h *Handler) SubscribeToIssue(w http.ResponseWriter, r *http.Request) { workspaceID := uuidToString(issue.WorkspaceID) callerID := requestUserID(r) - h.publish(protocol.EventSubscriberAdded, workspaceID, "member", callerID, map[string]any{ + subActorType, subActorID := h.resolveActor(r, callerID, workspaceID) + h.publish(protocol.EventSubscriberAdded, workspaceID, subActorType, subActorID, map[string]any{ "issue_id": issueID, "user_type": targetUserType, "user_id": targetUserID, @@ -136,7 +137,8 @@ func (h *Handler) UnsubscribeFromIssue(w http.ResponseWriter, r *http.Request) { workspaceID := uuidToString(issue.WorkspaceID) callerID := requestUserID(r) - h.publish(protocol.EventSubscriberRemoved, workspaceID, "member", callerID, map[string]any{ + unsubActorType, unsubActorID := h.resolveActor(r, callerID, workspaceID) + h.publish(protocol.EventSubscriberRemoved, workspaceID, unsubActorType, unsubActorID, map[string]any{ "issue_id": issueID, "user_type": targetUserType, "user_id": targetUserID, diff --git a/server/migrations/025_comment_workspace_id.down.sql b/server/migrations/025_comment_workspace_id.down.sql new file mode 100644 index 00000000..86a93d66 --- /dev/null +++ b/server/migrations/025_comment_workspace_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE comment DROP COLUMN workspace_id; diff --git a/server/migrations/025_comment_workspace_id.up.sql b/server/migrations/025_comment_workspace_id.up.sql new file mode 100644 index 00000000..ec56d79a --- /dev/null +++ b/server/migrations/025_comment_workspace_id.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE comment ADD COLUMN workspace_id UUID REFERENCES workspace(id) ON DELETE CASCADE; + +-- Backfill from issue.workspace_id +UPDATE comment SET workspace_id = issue.workspace_id +FROM issue WHERE comment.issue_id = issue.id; + +-- Make non-nullable after backfill +ALTER TABLE comment ALTER COLUMN workspace_id SET NOT NULL; diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index 0679cfdf..569718dc 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -333,6 +333,41 @@ func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) { return i, err } +const getAgentInWorkspace = `-- name: GetAgentInWorkspace :one +SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at, description, tools, triggers, runtime_id, instructions FROM agent +WHERE id = $1 AND workspace_id = $2 +` + +type GetAgentInWorkspaceParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) GetAgentInWorkspace(ctx context.Context, arg GetAgentInWorkspaceParams) (Agent, error) { + row := q.db.QueryRow(ctx, getAgentInWorkspace, arg.ID, arg.WorkspaceID) + 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.Tools, + &i.Triggers, + &i.RuntimeID, + &i.Instructions, + ) + return i, err +} + const getAgentTask = `-- name: GetAgentTask :one SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir FROM agent_task_queue WHERE id = $1 diff --git a/server/pkg/db/generated/comment.sql.go b/server/pkg/db/generated/comment.sql.go index 0217aee3..efaeb13c 100644 --- a/server/pkg/db/generated/comment.sql.go +++ b/server/pkg/db/generated/comment.sql.go @@ -12,23 +12,25 @@ import ( ) const createComment = `-- name: CreateComment :one -INSERT INTO comment (issue_id, author_type, author_id, content, type, parent_id) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id +INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id ` type CreateCommentParams struct { - IssueID pgtype.UUID `json:"issue_id"` - AuthorType string `json:"author_type"` - AuthorID pgtype.UUID `json:"author_id"` - Content string `json:"content"` - Type string `json:"type"` - ParentID pgtype.UUID `json:"parent_id"` + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + AuthorType string `json:"author_type"` + AuthorID pgtype.UUID `json:"author_id"` + Content string `json:"content"` + Type string `json:"type"` + ParentID pgtype.UUID `json:"parent_id"` } func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (Comment, error) { row := q.db.QueryRow(ctx, createComment, arg.IssueID, + arg.WorkspaceID, arg.AuthorType, arg.AuthorID, arg.Content, @@ -46,6 +48,7 @@ func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (C &i.CreatedAt, &i.UpdatedAt, &i.ParentID, + &i.WorkspaceID, ) return i, err } @@ -60,7 +63,7 @@ func (q *Queries) DeleteComment(ctx context.Context, id pgtype.UUID) error { } const getComment = `-- name: GetComment :one -SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id FROM comment +SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment WHERE id = $1 ` @@ -77,18 +80,52 @@ func (q *Queries) GetComment(ctx context.Context, id pgtype.UUID) (Comment, erro &i.CreatedAt, &i.UpdatedAt, &i.ParentID, + &i.WorkspaceID, + ) + return i, err +} + +const getCommentInWorkspace = `-- name: GetCommentInWorkspace :one +SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment +WHERE id = $1 AND workspace_id = $2 +` + +type GetCommentInWorkspaceParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) GetCommentInWorkspace(ctx context.Context, arg GetCommentInWorkspaceParams) (Comment, error) { + row := q.db.QueryRow(ctx, getCommentInWorkspace, arg.ID, arg.WorkspaceID) + var i Comment + err := row.Scan( + &i.ID, + &i.IssueID, + &i.AuthorType, + &i.AuthorID, + &i.Content, + &i.Type, + &i.CreatedAt, + &i.UpdatedAt, + &i.ParentID, + &i.WorkspaceID, ) return i, err } const listComments = `-- name: ListComments :many -SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id FROM comment -WHERE issue_id = $1 +SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id FROM comment +WHERE issue_id = $1 AND workspace_id = $2 ORDER BY created_at ASC ` -func (q *Queries) ListComments(ctx context.Context, issueID pgtype.UUID) ([]Comment, error) { - rows, err := q.db.Query(ctx, listComments, issueID) +type ListCommentsParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) ListComments(ctx context.Context, arg ListCommentsParams) ([]Comment, error) { + rows, err := q.db.Query(ctx, listComments, arg.IssueID, arg.WorkspaceID) if err != nil { return nil, err } @@ -106,6 +143,7 @@ func (q *Queries) ListComments(ctx context.Context, issueID pgtype.UUID) ([]Comm &i.CreatedAt, &i.UpdatedAt, &i.ParentID, + &i.WorkspaceID, ); err != nil { return nil, err } @@ -122,7 +160,7 @@ UPDATE comment SET content = $2, updated_at = now() WHERE id = $1 -RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id +RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id ` type UpdateCommentParams struct { @@ -143,6 +181,7 @@ func (q *Queries) UpdateComment(ctx context.Context, arg UpdateCommentParams) (C &i.CreatedAt, &i.UpdatedAt, &i.ParentID, + &i.WorkspaceID, ) return i, err } diff --git a/server/pkg/db/generated/inbox.sql.go b/server/pkg/db/generated/inbox.sql.go index b63e2ded..375f66e8 100644 --- a/server/pkg/db/generated/inbox.sql.go +++ b/server/pkg/db/generated/inbox.sql.go @@ -199,6 +199,39 @@ func (q *Queries) GetInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem, return i, err } +const getInboxItemInWorkspace = `-- name: GetInboxItemInWorkspace :one +SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id, details FROM inbox_item +WHERE id = $1 AND workspace_id = $2 +` + +type GetInboxItemInWorkspaceParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) GetInboxItemInWorkspace(ctx context.Context, arg GetInboxItemInWorkspaceParams) (InboxItem, error) { + row := q.db.QueryRow(ctx, getInboxItemInWorkspace, arg.ID, arg.WorkspaceID) + var i InboxItem + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.RecipientType, + &i.RecipientID, + &i.Type, + &i.Severity, + &i.IssueID, + &i.Title, + &i.Body, + &i.Read, + &i.Archived, + &i.CreatedAt, + &i.ActorType, + &i.ActorID, + &i.Details, + ) + return i, err +} + const listInboxItems = `-- name: ListInboxItems :many SELECT i.id, i.workspace_id, i.recipient_type, i.recipient_id, i.type, i.severity, i.issue_id, i.title, i.body, i.read, i.archived, i.created_at, i.actor_type, i.actor_id, i.details, iss.status as issue_status diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index aae29518..f899eb6e 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -153,6 +153,42 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara return i, err } +const getIssueInWorkspace = `-- name: GetIssueInWorkspace :one +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue +WHERE id = $1 AND workspace_id = $2 +` + +type GetIssueInWorkspaceParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspaceParams) (Issue, error) { + row := q.db.QueryRow(ctx, getIssueInWorkspace, arg.ID, arg.WorkspaceID) + var i Issue + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Title, + &i.Description, + &i.Status, + &i.Priority, + &i.AssigneeType, + &i.AssigneeID, + &i.CreatorType, + &i.CreatorID, + &i.ParentIssueID, + &i.AcceptanceCriteria, + &i.ContextRefs, + &i.Position, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.Number, + ) + return i, err +} + const listIssues = `-- name: ListIssues :many SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue WHERE workspace_id = $1 diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index c4a9df21..c91bfe1d 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -79,15 +79,16 @@ type AgentTaskQueue struct { } type Comment struct { - ID pgtype.UUID `json:"id"` - IssueID pgtype.UUID `json:"issue_id"` - AuthorType string `json:"author_type"` - AuthorID pgtype.UUID `json:"author_id"` - Content string `json:"content"` - Type string `json:"type"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - ParentID pgtype.UUID `json:"parent_id"` + ID pgtype.UUID `json:"id"` + IssueID pgtype.UUID `json:"issue_id"` + AuthorType string `json:"author_type"` + AuthorID pgtype.UUID `json:"author_id"` + Content string `json:"content"` + Type string `json:"type"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ParentID pgtype.UUID `json:"parent_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` } type DaemonConnection struct { diff --git a/server/pkg/db/generated/skill.sql.go b/server/pkg/db/generated/skill.sql.go index 95dac04d..11f42342 100644 --- a/server/pkg/db/generated/skill.sql.go +++ b/server/pkg/db/generated/skill.sql.go @@ -134,6 +134,33 @@ func (q *Queries) GetSkillFile(ctx context.Context, id pgtype.UUID) (SkillFile, return i, err } +const getSkillInWorkspace = `-- name: GetSkillInWorkspace :one +SELECT id, workspace_id, name, description, content, config, created_by, created_at, updated_at FROM skill +WHERE id = $1 AND workspace_id = $2 +` + +type GetSkillInWorkspaceParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) GetSkillInWorkspace(ctx context.Context, arg GetSkillInWorkspaceParams) (Skill, error) { + row := q.db.QueryRow(ctx, getSkillInWorkspace, arg.ID, arg.WorkspaceID) + var i Skill + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.Content, + &i.Config, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const listAgentSkills = `-- name: ListAgentSkills :many SELECT s.id, s.workspace_id, s.name, s.description, s.content, s.config, s.created_by, s.created_at, s.updated_at FROM skill s diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index 72084294..27816de2 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -7,6 +7,10 @@ ORDER BY created_at ASC; SELECT * FROM agent WHERE id = $1; +-- name: GetAgentInWorkspace :one +SELECT * FROM agent +WHERE id = $1 AND workspace_id = $2; + -- name: CreateAgent :one INSERT INTO agent ( workspace_id, name, description, avatar_url, runtime_mode, diff --git a/server/pkg/db/queries/comment.sql b/server/pkg/db/queries/comment.sql index 4648ad02..e26d2730 100644 --- a/server/pkg/db/queries/comment.sql +++ b/server/pkg/db/queries/comment.sql @@ -1,15 +1,19 @@ -- name: ListComments :many SELECT * FROM comment -WHERE issue_id = $1 +WHERE issue_id = $1 AND workspace_id = $2 ORDER BY created_at ASC; -- name: GetComment :one SELECT * FROM comment WHERE id = $1; +-- name: GetCommentInWorkspace :one +SELECT * FROM comment +WHERE id = $1 AND workspace_id = $2; + -- name: CreateComment :one -INSERT INTO comment (issue_id, author_type, author_id, content, type, parent_id) -VALUES ($1, $2, $3, $4, $5, sqlc.narg(parent_id)) +INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id) +VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(parent_id)) RETURNING *; -- name: UpdateComment :one diff --git a/server/pkg/db/queries/inbox.sql b/server/pkg/db/queries/inbox.sql index bff47a78..be0e9310 100644 --- a/server/pkg/db/queries/inbox.sql +++ b/server/pkg/db/queries/inbox.sql @@ -11,6 +11,10 @@ LIMIT $4 OFFSET $5; SELECT * FROM inbox_item WHERE id = $1; +-- name: GetInboxItemInWorkspace :one +SELECT * FROM inbox_item +WHERE id = $1 AND workspace_id = $2; + -- name: CreateInboxItem :one INSERT INTO inbox_item ( workspace_id, recipient_type, recipient_id, diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index ed5614ba..edc229c3 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -11,6 +11,10 @@ LIMIT $2 OFFSET $3; SELECT * FROM issue WHERE id = $1; +-- name: GetIssueInWorkspace :one +SELECT * FROM issue +WHERE id = $1 AND workspace_id = $2; + -- name: CreateIssue :one INSERT INTO issue ( workspace_id, title, description, status, priority, diff --git a/server/pkg/db/queries/skill.sql b/server/pkg/db/queries/skill.sql index bb42da36..c91460e8 100644 --- a/server/pkg/db/queries/skill.sql +++ b/server/pkg/db/queries/skill.sql @@ -9,6 +9,10 @@ ORDER BY name ASC; SELECT * FROM skill WHERE id = $1; +-- name: GetSkillInWorkspace :one +SELECT * FROM skill +WHERE id = $1 AND workspace_id = $2; + -- name: CreateSkill :one INSERT INTO skill (workspace_id, name, description, content, config, created_by) VALUES ($1, $2, $3, $4, $5, $6)