mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* feat(server): let agents steer running turns they could start (MUL-8285) An agent comment can now be added to another agent's running turn, the way a member steers from the composer: exactly when the comment could start that agent (canInvokeAgent, judged on the human at the top of the chain), never its own turn. - steer_task_ids now work for agent authors; steer: true asks the server to add the comment to the current running turn of every recipient without a named turn. - Agent-authored bindings are capped per turn (10) and per authoring agent (3). The task row is locked in its own statement before the bind, so the cap count sees what an earlier lock holder committed. - An agent comment that steered a turn but was never read is replayed on completion like the mention it replaced. Member receipts keep today's behaviour (the member sees the failure and can retry or resend). - The supplement claim returns author_type and the agent's name. A daemon that does not send task-supplement-author-v1 gets the attribution in the content, so an agent message never reads as a human's. - can_supplement on task-runs is computed for an agent caller by the same rule. Co-authored-by: Tim Smart <[email protected]> Co-authored-by: WitMiao <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(daemon): present an agent's added message as an agent's (MUL-8285) Claims send the task-supplement-author-v1 capability and render agent messages as peer input that cannot replace the objective or override humans. Co-authored-by: Tim Smart <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(cli): add --steer and --steer-active to issue comment add (MUL-8285) Docs: CLI help, CLI_AND_DAEMON.md, the CLI docs in every locale, the squad leader briefing, and a steering section in the multica-platform mentions reference. Co-authored-by: Tim Smart <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix: close the review gaps in agent steering (MUL-8285) - Replay an unread agent steer even when it reached the turn through an implicit route (a worker's reply to its leader): a comment bound to the completing run counts as planned input for the replay filter. - Drop the receipt of an agent steer once completion has replayed it, so no client (old or new) offers to resend what the follow-up already handles. - Never hand an agent's message to a daemon without task-supplement-author-v1: it would frame it as a human's. The message stays pending, settles when the turn ends, and is replayed as a mention. - Tell a squad leader about --steer only when its daemon advertises cli-comment-steer-v1, since an older CLI rejects the flag; the skill reference says to drop the flag on 'unknown flag'. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Tim Smart <[email protected]> Co-authored-by: WitMiao <[email protected]> Co-authored-by: multica-agent <[email protected]>
960 lines
35 KiB
Go
960 lines
35 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/dbid"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// Issue Quick Actions (MUL-5465): workspace-level presets for "who to call and
|
|
// what to say" on an existing issue.
|
|
//
|
|
// Contract highlights:
|
|
// - Running one is NOT a new dispatch path. It renders the prompt, posts an
|
|
// ordinary comment carrying optional target mention markup (marked with
|
|
// quick_action_id), and hands off to triggerTasksForComment. Permission, attribution, squad routing,
|
|
// the execution log, and pending-task coalescing are inherited from the
|
|
// comment path rather than reimplemented — the MUL-3375 lesson about four
|
|
// drifting copies of one trigger decision.
|
|
// - PERMISSION IS CHECKED IN EXACTLY ONE PLACE: RunQuickAction. The list
|
|
// endpoint does no permission work and hides nothing beyond `private`
|
|
// ownership. Filtering the sidebar by invoke permission was tried and
|
|
// removed: it made two people looking at one issue see different sidebars
|
|
// with no explanation, which is harder to debug than a button that says
|
|
// why it refused.
|
|
// - `visibility` is the author's stated intent, not an authorization
|
|
// decision. A `public` action may only bind a target every member can invoke,
|
|
// so it is runnable by construction; the run-time gate still has the final
|
|
// say, so a target that goes private later fails loudly at click time
|
|
// rather than silently granting anything.
|
|
// - The prompt is sent VERBATIM. There is no interpolation and no runtime
|
|
// input: every variable considered ({{issue.title}}, {{issue.identifier}},
|
|
// {{issue.url}}, {{user.name}}, {{date}}) named something the agent
|
|
// already has from the issue context and the comment's own author, and the
|
|
// `/` slash command covers "same action, one detail different" by dropping
|
|
// the rendered body into the composer to edit. Write-time validation still
|
|
// REJECTS any `{{...}}` so a habitual template token cannot land literally
|
|
// in an agent's instructions.
|
|
const (
|
|
maxActiveQuickActionsPerWorkspace = 30
|
|
maxQuickActionNameLen = 32
|
|
maxQuickActionDescriptionLen = 200
|
|
maxQuickActionPromptLen = 4000
|
|
)
|
|
|
|
// quickActionTemplateTokenRe catches any `{{...}}` in a prompt.
|
|
//
|
|
// Templating is deliberately unsupported, but silence is not the same as
|
|
// safety: someone carrying the habit over from autopilot's issue-title
|
|
// template would otherwise have `{{issue.title}}` rendered literally into an
|
|
// agent's instructions and never notice. Rejecting at write time is a fraction
|
|
// of the cost of the interpolation engine it replaces, and it keeps the door
|
|
// open to enabling variables later without touching stored data.
|
|
var quickActionTemplateTokenRe = regexp.MustCompile(`\{\{[^}]*\}\}`)
|
|
|
|
// quickActionSideEffectMentionRe catches mention markup that reaches somebody.
|
|
//
|
|
// The prompt is appended verbatim to a comment that then runs through the
|
|
// normal mention pipeline, so a mention inside it acts on every click:
|
|
//
|
|
// - agent / squad / all -> enqueues a SECOND target beside the configured
|
|
// one, breaking the invariant the permission model rests on (a public
|
|
// action runs exactly the target it was validated against). The sidebar
|
|
// reports only the first outcome, so the extra run is invisible.
|
|
// - member -> notification_listeners.go adds them to the mention recipients
|
|
// and creates an inbox item, so a saved prompt pings that person on every
|
|
// single click. This one was initially allowed on the reasoning that it
|
|
// "only renders a link"; that was wrong.
|
|
//
|
|
// `mention://issue/...` is the sole exception: it renders as a link and
|
|
// reaches nobody, so a prompt may legitimately point at related work.
|
|
var quickActionSideEffectMentionRe = regexp.MustCompile(`mention://(agent|squad|member|all)/`)
|
|
|
|
// Types
|
|
|
|
type QuickActionResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
AssigneeType string `json:"assignee_type"`
|
|
AssigneeID string `json:"assignee_id"`
|
|
Prompt string `json:"prompt"`
|
|
Visibility string `json:"visibility"`
|
|
Status string `json:"status"`
|
|
LastUsedAt *string `json:"last_used_at"`
|
|
UseCount int64 `json:"use_count"`
|
|
CreatedByID string `json:"created_by_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
|
|
// Target identity, for display. Always populated when the target resolves:
|
|
// a `public` action binds a publicly-invocable agent whose name everyone
|
|
// can already see, and a `private` action is only ever returned to its
|
|
// creator — so there is nothing left to redact.
|
|
TargetName string `json:"target_name,omitempty"`
|
|
// TargetPublic reports whether the bound target is currently invocable by
|
|
// every workspace member. Plain metadata, not a verdict: settings renders
|
|
// it beside the binding so a `public` action pointing at a now-private
|
|
// agent is visibly wrong without a bespoke "broken" state.
|
|
TargetPublic bool `json:"target_public"`
|
|
// TargetMissing marks an archived or deleted target.
|
|
TargetMissing bool `json:"target_missing"`
|
|
}
|
|
|
|
type CreateQuickActionRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
AssigneeType string `json:"assignee_type"`
|
|
AssigneeID string `json:"assignee_id"`
|
|
Prompt string `json:"prompt"`
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
|
|
type UpdateQuickActionRequest struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
AssigneeType *string `json:"assignee_type"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
Prompt *string `json:"prompt"`
|
|
Visibility *string `json:"visibility"`
|
|
Status *string `json:"status"`
|
|
}
|
|
|
|
type ListQuickActionsResponse struct {
|
|
QuickActions []QuickActionResponse `json:"quick_actions"`
|
|
}
|
|
|
|
// Validation
|
|
|
|
func validateQuickActionName(raw string) (string, error) {
|
|
name := strings.TrimSpace(raw)
|
|
if name == "" {
|
|
return "", fmt.Errorf("name is required")
|
|
}
|
|
if utf8.RuneCountInString(name) > maxQuickActionNameLen {
|
|
return "", fmt.Errorf("name must be at most %d characters", maxQuickActionNameLen)
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
// validateQuickActionPrompt trims, bounds, and refuses template syntax. See
|
|
// quickActionTemplateTokenRe for why the rejection outlives the feature.
|
|
func validateQuickActionPrompt(raw string) (string, error) {
|
|
prompt := strings.TrimSpace(raw)
|
|
if prompt == "" {
|
|
return "", fmt.Errorf("prompt is required")
|
|
}
|
|
if utf8.RuneCountInString(prompt) > maxQuickActionPromptLen {
|
|
return "", fmt.Errorf("prompt must be at most %d characters", maxQuickActionPromptLen)
|
|
}
|
|
if token := quickActionTemplateTokenRe.FindString(prompt); token != "" {
|
|
return "", fmt.Errorf("template variables are not supported yet; remove %s — the agent already reads this issue", token)
|
|
}
|
|
if quickActionSideEffectMentionRe.MatchString(prompt) {
|
|
return "", fmt.Errorf("the prompt cannot @mention an agent, squad, or person; a quick action reaches exactly the one target it is bound to (an issue link is fine)")
|
|
}
|
|
return prompt, nil
|
|
}
|
|
|
|
func validateQuickActionAssignee(assigneeType, assigneeID string) error {
|
|
if assigneeType == "" && assigneeID == "" {
|
|
return nil
|
|
}
|
|
if assigneeType != "agent" && assigneeType != "squad" {
|
|
return fmt.Errorf("assignee_type must be \"agent\" or \"squad\"")
|
|
}
|
|
if strings.TrimSpace(assigneeID) == "" {
|
|
return fmt.Errorf("assignee_id is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeQuickActionVisibility(raw string) (string, error) {
|
|
v := strings.TrimSpace(raw)
|
|
if v == "" {
|
|
return "public", nil
|
|
}
|
|
if v != "public" && v != "private" {
|
|
return "", fmt.Errorf("visibility must be \"public\" or \"private\"")
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// Target resolution
|
|
|
|
// quickActionTarget is the resolved execution target of an action: the agent
|
|
// that will actually run, plus the display identity the client should show.
|
|
// For a squad binding, Agent is the squad leader and Name is the SQUAD's name,
|
|
// because the squad is what the user bound and what the mention will address.
|
|
type quickActionTarget struct {
|
|
Agent db.Agent
|
|
Name string
|
|
AvatarURL string
|
|
// MentionType is the mention:// scheme the rendered comment must use so
|
|
// the existing trigger path routes it exactly as a hand-typed mention.
|
|
MentionType string
|
|
// MentionID is the id inside the mention link: the agent id, or the SQUAD
|
|
// id for a squad binding (the trigger path resolves the leader itself).
|
|
MentionID string
|
|
Found bool
|
|
}
|
|
|
|
// resolveQuickActionTarget loads the execution target. A missing/archived
|
|
// agent, or a missing/archived squad, resolves to Found=false — the caller
|
|
// renders that as an unavailable target rather than failing the whole list.
|
|
func (h *Handler) resolveQuickActionTarget(ctx context.Context, qa db.QuickAction) quickActionTarget {
|
|
switch qa.AssigneeType {
|
|
case "":
|
|
// An intentionally unset target is available, not an orphaned binding.
|
|
return quickActionTarget{Found: true}
|
|
case "squad":
|
|
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: qa.AssigneeID,
|
|
WorkspaceID: qa.WorkspaceID,
|
|
})
|
|
if err != nil || squad.ArchivedAt.Valid {
|
|
return quickActionTarget{}
|
|
}
|
|
leader, err := h.Queries.GetAgent(ctx, squad.LeaderID)
|
|
if err != nil || leader.ArchivedAt.Valid {
|
|
return quickActionTarget{}
|
|
}
|
|
avatar := ""
|
|
if squad.AvatarUrl.Valid {
|
|
avatar = squad.AvatarUrl.String
|
|
}
|
|
return quickActionTarget{
|
|
Agent: leader,
|
|
Name: squad.Name,
|
|
AvatarURL: avatar,
|
|
MentionType: "squad",
|
|
MentionID: uuidToString(squad.ID),
|
|
Found: true,
|
|
}
|
|
default:
|
|
agent, err := h.Queries.GetAgent(ctx, qa.AssigneeID)
|
|
if err != nil || agent.ArchivedAt.Valid || uuidToString(agent.WorkspaceID) != uuidToString(qa.WorkspaceID) {
|
|
return quickActionTarget{}
|
|
}
|
|
avatar := ""
|
|
if agent.AvatarUrl.Valid {
|
|
avatar = agent.AvatarUrl.String
|
|
}
|
|
return quickActionTarget{
|
|
Agent: agent,
|
|
Name: agent.Name,
|
|
AvatarURL: avatar,
|
|
MentionType: "agent",
|
|
MentionID: uuidToString(agent.ID),
|
|
Found: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
// agentInvocableByEveryone reports whether every workspace member may invoke
|
|
// this agent — `public_to` carrying a `workspace` target.
|
|
//
|
|
// A `public_to` agent whose allow-list names specific members does NOT count:
|
|
// binding one to a public quick action would make the "everyone" promise false
|
|
// for the members left out. Fails closed on a lookup error.
|
|
func (h *Handler) agentInvocableByEveryone(ctx context.Context, agent db.Agent) bool {
|
|
if agent.PermissionMode != "public_to" {
|
|
return false
|
|
}
|
|
targets, err := h.Queries.ListAgentInvocationTargets(ctx, agent.ID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, t := range targets {
|
|
if t.TargetType == "workspace" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// quickActionCatalog is a batched resolver for the list endpoint.
|
|
//
|
|
// The single-row path issues a GetAgent (plus GetSquad) AND a
|
|
// ListAgentInvocationTargets per action. At the 30-action cap that is 60-90
|
|
// sequential round-trips on every sidebar and settings load. This loads the
|
|
// workspace's agents, squads, and invocation targets in THREE queries and
|
|
// answers from memory. Workspaces are small-team scale, so listing all agents
|
|
// costs less than thirty point lookups.
|
|
type quickActionCatalog struct {
|
|
agents map[string]db.Agent
|
|
squads map[string]db.Squad
|
|
// publicAgents holds agent ids every workspace member may invoke.
|
|
publicAgents map[string]bool
|
|
}
|
|
|
|
func (h *Handler) loadQuickActionCatalog(ctx context.Context, workspaceID pgtype.UUID) quickActionCatalog {
|
|
cat := quickActionCatalog{
|
|
agents: map[string]db.Agent{},
|
|
squads: map[string]db.Squad{},
|
|
publicAgents: map[string]bool{},
|
|
}
|
|
|
|
agents, err := h.Queries.ListAgents(ctx, workspaceID)
|
|
if err != nil {
|
|
// Degrade to "target unresolved" rather than failing the whole list:
|
|
// the catalog is display metadata, and the run path re-checks
|
|
// everything that matters anyway.
|
|
return cat
|
|
}
|
|
agentIDs := make([]pgtype.UUID, 0, len(agents))
|
|
for _, a := range agents {
|
|
cat.agents[uuidToString(a.ID)] = a
|
|
agentIDs = append(agentIDs, a.ID)
|
|
}
|
|
|
|
if squads, err := h.Queries.ListSquads(ctx, workspaceID); err == nil {
|
|
for _, sq := range squads {
|
|
cat.squads[uuidToString(sq.ID)] = sq
|
|
}
|
|
}
|
|
|
|
if len(agentIDs) > 0 {
|
|
targets, err := h.Queries.ListAgentInvocationTargetsByAgentIDs(ctx, agentIDs)
|
|
if err == nil {
|
|
for _, t := range targets {
|
|
if t.TargetType == "workspace" {
|
|
cat.publicAgents[uuidToString(t.AgentID)] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return cat
|
|
}
|
|
|
|
// resolve mirrors resolveQuickActionTarget + agentInvocableByEveryone, but
|
|
// entirely from the pre-loaded maps. The two must agree; the shared shape of
|
|
// quickActionTarget is what keeps them honest.
|
|
func (c quickActionCatalog) resolve(qa db.QuickAction) (quickActionTarget, bool) {
|
|
if qa.AssigneeType == "" {
|
|
return quickActionTarget{Found: true}, true
|
|
}
|
|
if qa.AssigneeType == "squad" {
|
|
squad, ok := c.squads[uuidToString(qa.AssigneeID)]
|
|
if !ok || squad.ArchivedAt.Valid {
|
|
return quickActionTarget{}, false
|
|
}
|
|
leader, ok := c.agents[uuidToString(squad.LeaderID)]
|
|
if !ok {
|
|
return quickActionTarget{}, false
|
|
}
|
|
name := squad.Name
|
|
return quickActionTarget{Agent: leader, Name: name, Found: true},
|
|
c.publicAgents[uuidToString(leader.ID)] && leader.PermissionMode == "public_to"
|
|
}
|
|
agent, ok := c.agents[uuidToString(qa.AssigneeID)]
|
|
if !ok {
|
|
return quickActionTarget{}, false
|
|
}
|
|
return quickActionTarget{Agent: agent, Name: agent.Name, Found: true},
|
|
c.publicAgents[uuidToString(agent.ID)] && agent.PermissionMode == "public_to"
|
|
}
|
|
|
|
// quickActionToResponseFrom builds a row's response off the batched catalog.
|
|
func quickActionToResponseFrom(qa db.QuickAction, cat quickActionCatalog) QuickActionResponse {
|
|
resp := baseQuickActionResponse(qa)
|
|
target, targetPublic := cat.resolve(qa)
|
|
resp.TargetMissing = !target.Found
|
|
if target.Found {
|
|
resp.TargetName = target.Name
|
|
resp.TargetPublic = targetPublic
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// baseQuickActionResponse maps the stored columns. Target metadata is layered
|
|
// on by whichever resolver the caller used.
|
|
func baseQuickActionResponse(qa db.QuickAction) QuickActionResponse {
|
|
return QuickActionResponse{
|
|
ID: uuidToString(qa.ID),
|
|
WorkspaceID: uuidToString(qa.WorkspaceID),
|
|
Name: qa.Name,
|
|
Description: qa.Description,
|
|
AssigneeType: qa.AssigneeType,
|
|
AssigneeID: uuidToString(qa.AssigneeID),
|
|
Prompt: qa.Prompt,
|
|
Visibility: qa.Visibility,
|
|
Status: qa.Status,
|
|
LastUsedAt: timestampToPtr(qa.LastUsedAt),
|
|
UseCount: qa.UseCount,
|
|
CreatedByID: uuidToString(qa.CreatedByID),
|
|
CreatedAt: timestampToString(qa.CreatedAt),
|
|
UpdatedAt: timestampToString(qa.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
// quickActionToResponse is the single-row path, used by create/update where
|
|
// exactly one action is being returned and a batch would be pointless. The
|
|
// list endpoint uses quickActionToResponseFrom instead.
|
|
func (h *Handler) quickActionToResponse(ctx context.Context, qa db.QuickAction) QuickActionResponse {
|
|
resp := baseQuickActionResponse(qa)
|
|
target := h.resolveQuickActionTarget(ctx, qa)
|
|
resp.TargetMissing = !target.Found
|
|
if target.Found {
|
|
resp.TargetName = target.Name
|
|
resp.TargetPublic = qa.AssigneeType == "" || h.agentInvocableByEveryone(ctx, target.Agent)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// Management gate
|
|
|
|
// requireQuickActionActor resolves the acting member. Agents are rejected: an
|
|
// agent inherits its runtime owner's credentials, and without this an admin's
|
|
// agent could mass-produce actions that then appear in everyone's sidebar.
|
|
//
|
|
// The role check is deliberately NOT here — it depends on the visibility being
|
|
// written. A `public` action is workspace furniture and needs owner/admin; a
|
|
// `private` one is personal and any member may create it.
|
|
func (h *Handler) requireQuickActionActor(w http.ResponseWriter, r *http.Request) (workspaceID, userID string, ok bool) {
|
|
workspaceID = h.resolveWorkspaceID(r)
|
|
userID, ok = requireUserID(w, r)
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
if actorType, _ := h.resolveActor(r, userID, workspaceID); actorType == "agent" {
|
|
writeError(w, http.StatusForbidden, "agents cannot manage quick actions")
|
|
return "", "", false
|
|
}
|
|
if _, memberOK := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !memberOK {
|
|
return "", "", false
|
|
}
|
|
return workspaceID, userID, true
|
|
}
|
|
|
|
// loadReachableQuickAction fetches an action and enforces the ONE rule that
|
|
// governs who may touch a private one: it belongs to its creator and nobody
|
|
// else, whatever they know its id.
|
|
//
|
|
// This exists because the check used to be inline and only in RunQuickAction.
|
|
// Update and Render reached rows by (id, workspace) alone, so any member who
|
|
// learned a UUID — an action-created comment carries one — could rewrite or
|
|
// read back another member's private prompt. Centralizing it means a new
|
|
// endpoint cannot forget the rule by omission.
|
|
//
|
|
// A private action the caller does not own reports 404, not 403: whether a
|
|
// given UUID exists is itself not the caller's business.
|
|
func (h *Handler) loadReachableQuickAction(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
id, workspaceID pgtype.UUID,
|
|
userID string,
|
|
) (db.QuickAction, bool) {
|
|
qa, err := h.Queries.GetQuickAction(r.Context(), db.GetQuickActionParams{ID: id, WorkspaceID: workspaceID})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "quick action not found")
|
|
return db.QuickAction{}, false
|
|
}
|
|
if qa.Visibility == "private" && uuidToString(qa.CreatedByID) != userID {
|
|
writeError(w, http.StatusNotFound, "quick action not found")
|
|
return db.QuickAction{}, false
|
|
}
|
|
return qa, true
|
|
}
|
|
|
|
// requirePublicQuickActionRole gates writes that leave an action `public`.
|
|
// Returns false having already written the error response.
|
|
func (h *Handler) requirePublicQuickActionRole(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
|
|
_, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
|
|
return ok
|
|
}
|
|
|
|
// Handlers
|
|
|
|
// ListQuickActions returns the workspace catalog. It performs NO permission
|
|
// work: `private` rows are scoped to their creator by the query (that is what
|
|
// the field means, not a permission check), and every other row is returned to
|
|
// everyone. Whether the caller may RUN one is answered by RunQuickAction.
|
|
func (h *Handler) ListQuickActions(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
viewerUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
includeArchived := r.URL.Query().Get("include_archived") == "true"
|
|
rows, err := h.Queries.ListQuickActions(r.Context(), db.ListQuickActionsParams{
|
|
WorkspaceID: wsUUID,
|
|
IncludeArchived: includeArchived,
|
|
ViewerID: viewerUUID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("list quick actions failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to list quick actions")
|
|
return
|
|
}
|
|
|
|
catalog := h.loadQuickActionCatalog(r.Context(), wsUUID)
|
|
out := make([]QuickActionResponse, 0, len(rows))
|
|
for _, qa := range rows {
|
|
out = append(out, quickActionToResponseFrom(qa, catalog))
|
|
}
|
|
writeJSON(w, http.StatusOK, ListQuickActionsResponse{QuickActions: out})
|
|
}
|
|
|
|
func (h *Handler) CreateQuickAction(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, userID, ok := h.requireQuickActionActor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateQuickActionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
visibility, err := normalizeQuickActionVisibility(req.Visibility)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if visibility == "public" && !h.requirePublicQuickActionRole(w, r, workspaceID) {
|
|
return
|
|
}
|
|
name, err := validateQuickActionName(req.Name)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := validateQuickActionAssignee(req.AssigneeType, req.AssigneeID); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
var assigneeUUID pgtype.UUID
|
|
if req.AssigneeType != "" {
|
|
assigneeUUID, ok = parseUUIDOrBadRequest(w, req.AssigneeID, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
}
|
|
prompt, err := validateQuickActionPrompt(req.Prompt)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
description, ok := trimmedWithinLimit(w, req.Description, maxQuickActionDescriptionLen, "description")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.validateQuickActionBinding(w, r, req.AssigneeType, assigneeUUID, wsUUID, visibility) {
|
|
return
|
|
}
|
|
|
|
count, err := h.Queries.CountActiveQuickActions(r.Context(), wsUUID)
|
|
if err == nil && count >= maxActiveQuickActionsPerWorkspace {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("a workspace can have at most %d active quick actions; archive one first", maxActiveQuickActionsPerWorkspace))
|
|
return
|
|
}
|
|
|
|
qa, err := h.Queries.CreateQuickAction(r.Context(), db.CreateQuickActionParams{
|
|
WorkspaceID: wsUUID,
|
|
Name: name,
|
|
Description: description,
|
|
AssigneeType: req.AssigneeType,
|
|
AssigneeID: assigneeUUID,
|
|
Prompt: prompt,
|
|
Visibility: visibility,
|
|
CreatedByType: "member",
|
|
CreatedByID: parseUUID(userID),
|
|
})
|
|
if err != nil {
|
|
slog.Warn("create quick action failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to create quick action")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, h.quickActionToResponse(r.Context(), qa))
|
|
}
|
|
|
|
func (h *Handler) UpdateQuickAction(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, userID, ok := h.requireQuickActionActor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "quick action id")
|
|
if !ok {
|
|
return
|
|
}
|
|
existing, ok := h.loadReachableQuickAction(w, r, idUUID, wsUUID, userID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req UpdateQuickActionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
// The resulting visibility decides the role needed, so resolve it first.
|
|
// Editing an already-public action also requires the role, otherwise a
|
|
// member could rewrite the prompt behind a workspace-wide button.
|
|
visibility := existing.Visibility
|
|
if req.Visibility != nil {
|
|
normalized, err := normalizeQuickActionVisibility(*req.Visibility)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
visibility = normalized
|
|
}
|
|
if (visibility == "public" || existing.Visibility == "public") && !h.requirePublicQuickActionRole(w, r, workspaceID) {
|
|
return
|
|
}
|
|
|
|
params := db.UpdateQuickActionParams{ID: idUUID, WorkspaceID: wsUUID}
|
|
if req.Visibility != nil {
|
|
params.Visibility = pgtype.Text{String: visibility, Valid: true}
|
|
}
|
|
|
|
if req.Name != nil {
|
|
name, err := validateQuickActionName(*req.Name)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
params.Name = pgtype.Text{String: name, Valid: true}
|
|
}
|
|
if req.Description != nil {
|
|
description, ok := trimmedWithinLimit(w, *req.Description, maxQuickActionDescriptionLen, "description")
|
|
if !ok {
|
|
return
|
|
}
|
|
params.Description = pgtype.Text{String: description, Valid: true}
|
|
}
|
|
|
|
// assignee_type and assignee_id move together so a type swap can never
|
|
// land with a mismatched id (the same rule autopilot enforces). The
|
|
// binding is re-validated against the RESULTING visibility, so flipping an
|
|
// action to public with a private agent still bound is caught here.
|
|
newType := existing.AssigneeType
|
|
newID := uuidToString(existing.AssigneeID)
|
|
if req.AssigneeType != nil {
|
|
newType = *req.AssigneeType
|
|
}
|
|
if req.AssigneeID != nil {
|
|
newID = *req.AssigneeID
|
|
}
|
|
if err := validateQuickActionAssignee(newType, newID); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
var newUUID pgtype.UUID
|
|
if newType != "" {
|
|
newUUID, ok = parseUUIDOrBadRequest(w, newID, "assignee_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
}
|
|
if req.AssigneeType != nil || req.AssigneeID != nil {
|
|
params.AssigneeType = pgtype.Text{String: newType, Valid: true}
|
|
params.AssigneeID = newUUID
|
|
}
|
|
if !h.validateQuickActionBinding(w, r, newType, newUUID, wsUUID, visibility) {
|
|
return
|
|
}
|
|
|
|
if req.Prompt != nil {
|
|
prompt, err := validateQuickActionPrompt(*req.Prompt)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
params.Prompt = pgtype.Text{String: prompt, Valid: true}
|
|
}
|
|
if req.Status != nil {
|
|
if *req.Status != "active" && *req.Status != "archived" {
|
|
writeError(w, http.StatusBadRequest, "status must be \"active\" or \"archived\"")
|
|
return
|
|
}
|
|
if *req.Status == "active" && existing.Status != "active" {
|
|
count, err := h.Queries.CountActiveQuickActions(r.Context(), wsUUID)
|
|
if err == nil && count >= maxActiveQuickActionsPerWorkspace {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("a workspace can have at most %d active quick actions", maxActiveQuickActionsPerWorkspace))
|
|
return
|
|
}
|
|
}
|
|
params.Status = pgtype.Text{String: *req.Status, Valid: true}
|
|
}
|
|
|
|
qa, err := h.Queries.UpdateQuickAction(r.Context(), params)
|
|
if err != nil {
|
|
slog.Warn("update quick action failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to update quick action")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, h.quickActionToResponse(r.Context(), qa))
|
|
}
|
|
|
|
func (h *Handler) DeleteQuickAction(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, userID, ok := h.requireQuickActionActor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "quick action id")
|
|
if !ok {
|
|
return
|
|
}
|
|
existing, ok := h.loadReachableQuickAction(w, r, idUUID, wsUUID, userID)
|
|
if !ok {
|
|
return
|
|
}
|
|
// A public action is workspace furniture; a private one belongs to its
|
|
// creator and nobody else may remove it.
|
|
if existing.Visibility == "public" {
|
|
if !h.requirePublicQuickActionRole(w, r, workspaceID) {
|
|
return
|
|
}
|
|
} else if uuidToString(existing.CreatedByID) != userID {
|
|
writeError(w, http.StatusForbidden, "only the creator can delete a private quick action")
|
|
return
|
|
}
|
|
|
|
if err := h.Queries.DeleteQuickAction(r.Context(), db.DeleteQuickActionParams{ID: idUUID, WorkspaceID: wsUUID}); err != nil {
|
|
slog.Warn("delete quick action failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to delete quick action")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// trimmedWithinLimit trims a free-text field and enforces its rune ceiling,
|
|
// writing the 400 itself so callers stay linear.
|
|
func trimmedWithinLimit(w http.ResponseWriter, raw string, limit int, field string) (string, bool) {
|
|
v := strings.TrimSpace(raw)
|
|
if utf8.RuneCountInString(v) > limit {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("%s must be at most %d characters", field, limit))
|
|
return "", false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// validateQuickActionBinding checks that the target exists in this workspace
|
|
// and, for a `public` action, that every member can invoke it. Writes the 400
|
|
// itself and returns false when the binding is rejected.
|
|
func (h *Handler) validateQuickActionBinding(w http.ResponseWriter, r *http.Request, assigneeType string, id, workspaceID pgtype.UUID, visibility string) bool {
|
|
if assigneeType == "" {
|
|
return true
|
|
}
|
|
qa := db.QuickAction{AssigneeType: assigneeType, AssigneeID: id, WorkspaceID: workspaceID}
|
|
target := h.resolveQuickActionTarget(r.Context(), qa)
|
|
if !target.Found {
|
|
writeError(w, http.StatusBadRequest, "assignee not found in this workspace")
|
|
return false
|
|
}
|
|
// A public action promises "everyone can run this". Binding a target the
|
|
// team cannot invoke would make that promise false on the first click, so
|
|
// it is refused at write time instead.
|
|
if visibility == "public" && !h.agentInvocableByEveryone(r.Context(), target.Agent) {
|
|
writeError(w, http.StatusBadRequest, "a public quick action must use an agent every workspace member can trigger; make the agent public or set this action to private")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// RenderQuickAction returns what the action WOULD post, without posting it.
|
|
//
|
|
// This backs the composer hand-off (the `/` slash command): the user gets the
|
|
// fully rendered text in the comment box to edit before sending. Rendering
|
|
// stays on the server even for this read-only path so there is exactly one
|
|
// interpolation implementation — a client-side copy would drift from the run
|
|
// path and quietly send something different from the preview.
|
|
func (h *Handler) RenderQuickAction(w http.ResponseWriter, r *http.Request) {
|
|
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := uuidToString(issue.WorkspaceID)
|
|
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "quickActionId"), "quick action id")
|
|
if !ok {
|
|
return
|
|
}
|
|
qa, ok := h.loadReachableQuickAction(w, r, idUUID, issue.WorkspaceID, userID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
target := h.resolveQuickActionTarget(r.Context(), qa)
|
|
if !target.Found {
|
|
h.writeDispatchBlocked(w, http.StatusConflict, ReasonTargetUnavailable)
|
|
return
|
|
}
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
// Same gate as the run path: the preview would otherwise hand a user the
|
|
// exact text needed to trigger an agent they may not invoke.
|
|
if qa.AssigneeType != "" && !h.canInvokeAgent(r.Context(), target.Agent, actorType, actorID, h.invokeOriginatorFromRequest(r, actorType, actorID), workspaceID) {
|
|
h.writeDispatchBlocked(w, http.StatusForbidden, ReasonInvocationNotAllowed)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"content": buildQuickActionBody(qa, target),
|
|
})
|
|
}
|
|
|
|
// buildQuickActionBody prepends the mention line that actually triggers the
|
|
// run. It is parsed by the same ParseMentions the composer's output goes
|
|
// through, so a quick action is indistinguishable from a hand-typed @mention
|
|
// downstream. The prompt itself is passed through untouched — see the file
|
|
// header for why there is no interpolation step.
|
|
func buildQuickActionBody(qa db.QuickAction, target quickActionTarget) string {
|
|
if qa.AssigneeType == "" {
|
|
return qa.Prompt
|
|
}
|
|
return fmt.Sprintf("[@%s](mention://%s/%s)\n\n%s", target.Name, target.MentionType, target.MentionID, qa.Prompt)
|
|
}
|
|
|
|
// RunQuickAction renders the action and posts it as a quick_action comment,
|
|
// then hands off to the standard comment trigger path.
|
|
//
|
|
// This is the ONLY place invoke permission is checked. The list endpoint hides
|
|
// nothing beyond `private` ownership, so a member may well click an action
|
|
// they cannot run; that returns a structured 403 the client renders as a
|
|
// dialog, which is more informative than a button that silently was not there.
|
|
//
|
|
// The success response is a CommentResponse carrying trigger_outcomes,
|
|
// identical in shape to POST /comments — so the client reuses one result
|
|
// handler and gets `coalesced` (merged into the target's pending task),
|
|
// `deferred` (runtime offline), and `blocked` for free.
|
|
func (h *Handler) RunQuickAction(w http.ResponseWriter, r *http.Request) {
|
|
issue, ok := h.loadIssueForUser(w, r, chi.URLParam(r, "id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
workspaceID := uuidToString(issue.WorkspaceID)
|
|
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "quickActionId"), "quick action id")
|
|
if !ok {
|
|
return
|
|
}
|
|
qa, ok := h.loadReachableQuickAction(w, r, idUUID, issue.WorkspaceID, userID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if qa.Status != "active" {
|
|
writeError(w, http.StatusBadRequest, "quick action is archived")
|
|
return
|
|
}
|
|
// A quick action carries its OWN configured target, so under the "derived vs
|
|
// named" rule it could be let through. It is refused in the first phase for a
|
|
// product reason rather than a rule one: it is an instruction to go and DO
|
|
// the action, not an invitation to talk, and Triage is where nobody has
|
|
// agreed the work should be done yet. Opening it later is deleting this if.
|
|
//
|
|
// Before the comment is written, not after: a targeted action is a comment
|
|
// AND a run (MUL-7189 §2.3). Targetless actions are ordinary messages, so
|
|
// they can be posted in triage without dispatching work.
|
|
if qa.AssigneeType != "" && issue.TriageState.Valid {
|
|
h.writeDispatchBlocked(w, http.StatusForbidden, ReasonIssueInTriage)
|
|
return
|
|
}
|
|
|
|
target := h.resolveQuickActionTarget(r.Context(), qa)
|
|
if !target.Found {
|
|
h.writeDispatchBlocked(w, http.StatusConflict, ReasonTargetUnavailable)
|
|
return
|
|
}
|
|
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
originatorUserID := h.invokeOriginatorFromRequest(r, actorType, actorID)
|
|
// The single permission gate. Issue visibility never implies the right to
|
|
// trigger someone's private agent.
|
|
if qa.AssigneeType != "" && !h.canInvokeAgent(r.Context(), target.Agent, actorType, actorID, originatorUserID, workspaceID) {
|
|
h.writeDispatchBlocked(w, http.StatusForbidden, ReasonInvocationNotAllowed)
|
|
return
|
|
}
|
|
|
|
body := sanitizeNullBytes(buildQuickActionBody(qa, target))
|
|
|
|
created, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
|
|
ID: dbid.NewV7(),
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
AuthorType: actorType,
|
|
AuthorID: parseUUID(actorID),
|
|
Content: body,
|
|
// Deliberately an ordinary comment. The collapsed card is driven by
|
|
// quick_action_id, which no client can set — `type` is client-supplied
|
|
// on the generic comment endpoint, so a dedicated type value would be
|
|
// forgeable and would also have cost a CHECK rebuild on a hot table.
|
|
Type: "comment",
|
|
QuickActionID: qa.ID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("quick action comment create failed", append(logger.RequestAttrs(r), "error", err, "quick_action_id", uuidToString(qa.ID))...)
|
|
writeError(w, http.StatusInternalServerError, "failed to run quick action")
|
|
return
|
|
}
|
|
comment := created.Comment()
|
|
|
|
resp := commentToResponse(comment, nil, nil)
|
|
resp.IssueRevision = created.IssueRevision
|
|
h.publish(protocol.EventCommentCreated, workspaceID, actorType, actorID, map[string]any{
|
|
"comment": resp,
|
|
"issue_title": issue.Title,
|
|
"issue_assignee_type": textToPtr(issue.AssigneeType),
|
|
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
|
|
"issue_status": issue.Status,
|
|
"issue_revision": created.IssueRevision,
|
|
})
|
|
|
|
resp.TriggerOutcomes = h.triggerTasksForComment(r.Context(), issue, comment, nil, actorType, actorID, originatorUserID, nil, commentSteerRequest{})
|
|
|
|
// Usage telemetry is best-effort and deliberately outside the run's
|
|
// success path: a failed counter must never cost the user the run.
|
|
if err := h.Queries.TouchQuickActionUsage(r.Context(), db.TouchQuickActionUsageParams{ID: qa.ID, WorkspaceID: issue.WorkspaceID}); err != nil {
|
|
slog.Debug("quick action usage touch failed", "quick_action_id", uuidToString(qa.ID), "error", err)
|
|
}
|
|
|
|
slog.Info("quick action run", append(logger.RequestAttrs(r),
|
|
"quick_action_id", uuidToString(qa.ID),
|
|
"issue_id", uuidToString(issue.ID),
|
|
"comment_id", uuidToString(comment.ID),
|
|
)...)
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|