mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
- issueworkflow.Brief is the only renderer: the claim carries it into every run's per-turn prompt; the editor preview and timeline show the same text. - Workflow handoffs stop writing the brief into handoff_note, so a person's note no longer drops the brief and the handoff framing fits again. - Agents that are not the issue's assignee get an observer brief without the step's commands. - Clearer rule override and narrower wakeup guidance; user text is stripped of control characters. - multica-platform skill documents project workflows. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
391 lines
15 KiB
Go
391 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/issuestatus"
|
|
"github.com/multica-ai/multica/server/internal/issueworkflow"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// Project workflows on the issue write paths (MUL-7420).
|
|
//
|
|
// Two rules apply to an issue in a project:
|
|
// - when the project uses a named workflow, its status must be one the
|
|
// workflow lists; a move into such a project keeps an equivalent status
|
|
// instead of failing. Default accepts every status;
|
|
// - entering a column the project hands off, through an ordinary status
|
|
// change of an issue that stays in its project, assigns the issue to that
|
|
// handler and starts its run with the step's instructions. A write that
|
|
// sets the assignee itself keeps its explicit choice. A project move
|
|
// enters the column it lands on only when the caller asks for it.
|
|
//
|
|
// Handoffs reuse assignment wholesale: the resolved handler goes through the
|
|
// same permission gate as a hand-picked assignee, and the run starts through
|
|
// the same WillEnqueueRun predicate, with the step's brief as its handoff note.
|
|
|
|
// workflowHandoff is a handoff an issue write triggers.
|
|
type workflowHandoff struct {
|
|
def issueworkflow.Definition
|
|
step issueworkflow.Step
|
|
project db.Project
|
|
}
|
|
|
|
// applyIssueWorkflow enforces prev's (possibly new) project workflow on one
|
|
// prospective update and may rewrite params: the status of an issue moving
|
|
// into the project, and the assignee when the write enters a handoff step.
|
|
// enterOnMove makes a project move enter the step the issue lands on.
|
|
// run is the calling agent's run on this issue (nil for members): it may only
|
|
// move the issue from the step it works on. It returns the handoff that write
|
|
// triggers, or nil.
|
|
func (h *Handler) applyIssueWorkflow(ctx context.Context, prev db.Issue, params *db.UpdateIssueParams, statusExplicit, assigneeTouched, enterOnMove bool, run *db.AgentTaskQueue) (*workflowHandoff, error) {
|
|
def, project, err := issueworkflow.ForProject(ctx, h.Queries, prev.WorkspaceID, params.ProjectID)
|
|
if err != nil || def == nil {
|
|
return nil, err
|
|
}
|
|
projectMoving := params.ProjectID != prev.ProjectID
|
|
if projectMoving && !statusExplicit && !def.IsDefault && !def.Has(prev.Status) {
|
|
target := issueworkflow.MoveTarget(*def, prev.Status, func(key string) string {
|
|
return issuestatus.Category(ctx, h.Queries, prev.WorkspaceID, key)
|
|
})
|
|
params.Status = pgtype.Text{String: target, Valid: true}
|
|
}
|
|
if params.Status.Valid {
|
|
if err := issueworkflow.CheckStatus(def, params.Status.String); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Someone else moved the issue while the run worked: the run's decision was
|
|
// made for a step the issue has left, and applying it would undo that move.
|
|
// Default without handoffs keeps the pre-workflow behavior, so workspaces
|
|
// that never adopt workflows see no change.
|
|
if run != nil && run.WorkflowStep.Valid && run.WorkflowStep.String != prev.Status &&
|
|
(!def.IsDefault || def.HandsOffAnywhere()) &&
|
|
statusExplicit && params.Status.Valid && params.Status.String != prev.Status {
|
|
return nil, h.stepMovedError(ctx, prev, run.WorkflowStep.String)
|
|
}
|
|
if assigneeTouched || prev.TriageState.Valid {
|
|
return nil, nil
|
|
}
|
|
entering := ""
|
|
switch {
|
|
case projectMoving && enterOnMove:
|
|
entering = prev.Status
|
|
if params.Status.Valid {
|
|
entering = params.Status.String
|
|
}
|
|
// A project move is administrative unless the caller opted in, and
|
|
// re-selecting the current status is a no-op: neither hands the issue off.
|
|
case !projectMoving && statusExplicit && params.Status.Valid && params.Status.String != prev.Status:
|
|
entering = params.Status.String
|
|
default:
|
|
return nil, nil
|
|
}
|
|
step, ok := def.Step(entering)
|
|
if !ok || !step.HandsOff() {
|
|
return nil, nil
|
|
}
|
|
assigneeType, assigneeID, ok := issueworkflow.ResolveHandler(step, project, prev.CreatorType, prev.CreatorID)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
params.AssigneeType = pgtype.Text{String: assigneeType, Valid: true}
|
|
params.AssigneeID = assigneeID
|
|
return &workflowHandoff{def: *def, step: step, project: project}, nil
|
|
}
|
|
|
|
// workflowHandoffPayload describes a handoff on the issue:updated event, so
|
|
// the activity log can record the status change and the handoff as one
|
|
// timeline entry.
|
|
func workflowHandoffPayload(issue db.Issue, hand *workflowHandoff, note string, runStarted bool) map[string]string {
|
|
out := map[string]string{
|
|
"workflow": hand.def.Name,
|
|
"to_type": issue.AssigneeType.String,
|
|
"to_id": uuidToString(issue.AssigneeID),
|
|
"run": strconv.FormatBool(runStarted),
|
|
}
|
|
if runStarted && note != "" {
|
|
out["note"] = note
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writeIssueWorkflowError answers a workflow rejection. It reports false when
|
|
// err is not one, so the caller can fall through to its generic 500.
|
|
func writeIssueWorkflowError(w http.ResponseWriter, err error) bool {
|
|
var notIn *issueworkflow.NotInWorkflowError
|
|
if errors.As(err, ¬In) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": notIn.Error(),
|
|
"code": "status_not_in_workflow",
|
|
"workflow_name": notIn.WorkflowName,
|
|
"allowed_statuses": notIn.Allowed,
|
|
})
|
|
return true
|
|
}
|
|
var moved *issueworkflow.StepMovedError
|
|
if errors.As(err, &moved) {
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": moved.Error(),
|
|
"code": "workflow_step_moved",
|
|
"step": moved.Step,
|
|
"current_status": moved.Current,
|
|
})
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// agentRunOnIssue returns the calling agent's run when it works on this issue,
|
|
// nil for members and for runs on other issues. The task id is the
|
|
// server-trusted X-Task-ID resolveActor vouched for.
|
|
func (h *Handler) agentRunOnIssue(r *http.Request, actorType string, issue db.Issue) *db.AgentTaskQueue {
|
|
if actorType != "agent" {
|
|
return nil
|
|
}
|
|
taskID, err := util.ParseUUID(r.Header.Get("X-Task-ID"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
task, err := h.Queries.GetAgentTask(r.Context(), taskID)
|
|
if err != nil || task.IssueID != issue.ID {
|
|
return nil
|
|
}
|
|
return &task
|
|
}
|
|
|
|
// handedToAgent names the agent a write hands the issue to: its new agent
|
|
// assignee, the leader of its new squad assignee, or a workflow step's
|
|
// handler even when that handler already owned the issue. Empty when the
|
|
// write leaves the issue with whoever had it.
|
|
func (h *Handler) handedToAgent(ctx context.Context, prev db.Issue, params db.UpdateIssueParams, handoff bool) string {
|
|
if !params.AssigneeID.Valid {
|
|
return ""
|
|
}
|
|
if !handoff && params.AssigneeType == prev.AssigneeType && params.AssigneeID == prev.AssigneeID {
|
|
return ""
|
|
}
|
|
switch params.AssigneeType.String {
|
|
case "agent":
|
|
return uuidToString(params.AssigneeID)
|
|
case "squad":
|
|
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{ID: params.AssigneeID, WorkspaceID: prev.WorkspaceID})
|
|
if err == nil && squad.LeaderID.Valid {
|
|
return uuidToString(squad.LeaderID)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// advanceRunStep moves a run's workflow step along with a status change the
|
|
// run made itself, so its next change is judged from where it put the issue.
|
|
func (h *Handler) advanceRunStep(ctx context.Context, run *db.AgentTaskQueue, prev, issue db.Issue) {
|
|
if run == nil || !run.WorkflowStep.Valid || prev.Status == issue.Status {
|
|
return
|
|
}
|
|
if err := h.Queries.SetTaskWorkflowStep(ctx, db.SetTaskWorkflowStepParams{
|
|
ID: run.ID,
|
|
WorkflowStep: pgtype.Text{String: issue.Status, Valid: true},
|
|
}); err != nil {
|
|
slog.Warn("workflow: advance run step failed", "task_id", uuidToString(run.ID), "error", err)
|
|
}
|
|
}
|
|
|
|
// stepMovedError explains a refused status change: where the run's step was,
|
|
// where the issue is now, and who moved it.
|
|
func (h *Handler) stepMovedError(ctx context.Context, issue db.Issue, step string) error {
|
|
resolver := issuestatus.NewResolver(issue.WorkspaceID)
|
|
out := &issueworkflow.StepMovedError{
|
|
IssueIdentifier: h.getIssuePrefix(ctx, issue.WorkspaceID) + "-" + strconv.Itoa(int(issue.Number)),
|
|
Step: step,
|
|
StepName: resolver.Name(ctx, h.Queries, step),
|
|
Current: issue.Status,
|
|
CurrentName: resolver.Name(ctx, h.Queries, issue.Status),
|
|
}
|
|
if last, err := h.Queries.GetLastIssueStatusChange(ctx, issue.ID); err == nil && last.ActorID.Valid {
|
|
out.MovedBy = h.actorDisplayName(ctx, issue.WorkspaceID, last.ActorType.String, last.ActorID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// workflowHandoffBrief renders the brief a handoff's agent or squad will
|
|
// read, for the timeline and the handoff confirmation. Members run nothing,
|
|
// so they get none. The run itself reads the brief its claim renders.
|
|
func (h *Handler) workflowHandoffBrief(ctx context.Context, issue db.Issue, hand *workflowHandoff) string {
|
|
if hand == nil || !issue.AssigneeType.Valid {
|
|
return ""
|
|
}
|
|
if t := issue.AssigneeType.String; t != "agent" && t != "squad" {
|
|
return ""
|
|
}
|
|
in := h.workflowBriefInput(ctx, issue, hand.def, hand.project)
|
|
in.StatusKey = hand.step.StatusKey
|
|
return issueworkflow.Brief(in)
|
|
}
|
|
|
|
// workflowBriefInput describes an issue at its current status under its
|
|
// project's workflow, for the assignee's brief.
|
|
func (h *Handler) workflowBriefInput(ctx context.Context, issue db.Issue, def issueworkflow.Definition, project db.Project) issueworkflow.BriefInput {
|
|
resolver := issuestatus.NewResolver(issue.WorkspaceID)
|
|
return issueworkflow.BriefInput{
|
|
Workflow: def,
|
|
ProjectName: project.Title,
|
|
StatusKey: issue.Status,
|
|
IssueIdentifier: h.getIssuePrefix(ctx, issue.WorkspaceID) + "-" + strconv.Itoa(int(issue.Number)),
|
|
StatusName: func(key string) string {
|
|
return resolver.Name(ctx, h.Queries, key)
|
|
},
|
|
HandlerName: func(step issueworkflow.Step) string {
|
|
if !step.HandsOff() {
|
|
return ""
|
|
}
|
|
return h.stepHandlerName(ctx, issue, project, step)
|
|
},
|
|
}
|
|
}
|
|
|
|
// actorDisplayName names an agent, squad or member for a brief. It falls back
|
|
// to the actor type so a lookup failure never blocks a handoff.
|
|
func (h *Handler) actorDisplayName(ctx context.Context, wsUUID pgtype.UUID, actorType string, id pgtype.UUID) string {
|
|
switch actorType {
|
|
case "agent":
|
|
if agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{ID: id, WorkspaceID: wsUUID}); err == nil {
|
|
return agent.Name
|
|
}
|
|
case "squad":
|
|
if squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{ID: id, WorkspaceID: wsUUID}); err == nil {
|
|
return squad.Name
|
|
}
|
|
case "member":
|
|
if user, err := h.Queries.GetUser(ctx, id); err == nil {
|
|
return user.Name
|
|
}
|
|
}
|
|
return actorType
|
|
}
|
|
|
|
// stopPreviousAssigneeRuns cancels the active runs of whoever a handoff took
|
|
// the issue from — an agent's runs, or every run made on a squad's behalf —
|
|
// when the caller asked for it. A handoff back to the same handler keeps its
|
|
// run, and the agent making the handoff keeps its own: that run is the one
|
|
// making this write.
|
|
func (h *Handler) stopPreviousAssigneeRuns(ctx context.Context, prev, issue db.Issue, actorType, actorID string) {
|
|
if issue.AssigneeType == prev.AssigneeType && issue.AssigneeID == prev.AssigneeID {
|
|
return
|
|
}
|
|
tasks := h.assigneeActiveRuns(ctx, prev)
|
|
if len(tasks) == 0 {
|
|
return
|
|
}
|
|
actor := service.TaskCancellationActor{Type: actorType}
|
|
if id, err := parseUUIDString(actorID); err == nil {
|
|
actor.ID = id
|
|
actor.Name = h.actorDisplayName(ctx, issue.WorkspaceID, actorType, id)
|
|
}
|
|
for _, task := range tasks {
|
|
if actorType == "agent" && uuidToString(task.AgentID) == actorID {
|
|
continue
|
|
}
|
|
if _, err := h.TaskService.CancelTaskByUser(ctx, task.ID, actor); err != nil {
|
|
slog.Warn("workflow: cancel previous run failed",
|
|
"issue_id", uuidToString(issue.ID), "task_id", uuidToString(task.ID), "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// assigneeActiveRuns lists the active runs of an issue's assignee on it: the
|
|
// agent's own runs, or every run made on a squad's behalf. Members run
|
|
// nothing.
|
|
func (h *Handler) assigneeActiveRuns(ctx context.Context, issue db.Issue) []db.AgentTaskQueue {
|
|
if !issue.AssigneeID.Valid {
|
|
return nil
|
|
}
|
|
assigneeType := issue.AssigneeType.String
|
|
if assigneeType != "agent" && assigneeType != "squad" {
|
|
return nil
|
|
}
|
|
tasks, err := h.Queries.ListActiveTasksByIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
slog.Warn("workflow: list active tasks failed", "issue_id", uuidToString(issue.ID), "error", err)
|
|
return nil
|
|
}
|
|
var out []db.AgentTaskQueue
|
|
for _, task := range tasks {
|
|
if (assigneeType == "agent" && task.AgentID == issue.AssigneeID) ||
|
|
(assigneeType == "squad" && task.SquadID == issue.AssigneeID) {
|
|
out = append(out, task)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// claimProjectWorkflow renders the issue's project workflow for a task claim,
|
|
// or nil when there is nothing to say beyond the runtime's own status rules:
|
|
// no project, or Default with no handoffs. The issue's assignee gets the
|
|
// step's instructions and commands; any other agent working on the issue
|
|
// gets the observer brief, so it does not move a step it does not own.
|
|
func (h *Handler) claimProjectWorkflow(ctx context.Context, issue db.Issue, task *db.AgentTaskQueue) *TaskProjectWorkflowData {
|
|
def, project, err := issueworkflow.ForProject(ctx, h.Queries, issue.WorkspaceID, issue.ProjectID)
|
|
if err != nil {
|
|
slog.Warn("task claim: failed to load project workflow for brief injection",
|
|
"issue_id", uuidToString(issue.ID), "error", err)
|
|
return nil
|
|
}
|
|
if def == nil || (def.IsDefault && !def.HandsOffAnywhere()) {
|
|
return nil
|
|
}
|
|
in := h.workflowBriefInput(ctx, issue, *def, project)
|
|
if !taskRunsForAssignee(issue, task) {
|
|
in.Observer = true
|
|
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
|
|
in.AssigneeName = h.actorDisplayName(ctx, issue.WorkspaceID, issue.AssigneeType.String, issue.AssigneeID)
|
|
}
|
|
}
|
|
return &TaskProjectWorkflowData{Brief: issueworkflow.Brief(in)}
|
|
}
|
|
|
|
// taskRunsForAssignee reports whether a run works on the issue as its
|
|
// assignee: the assigned agent's own run, or a run made for the assigned
|
|
// squad.
|
|
func taskRunsForAssignee(issue db.Issue, task *db.AgentTaskQueue) bool {
|
|
if task == nil || !issue.AssigneeID.Valid {
|
|
return false
|
|
}
|
|
switch issue.AssigneeType.String {
|
|
case "agent":
|
|
return task.AgentID == issue.AssigneeID
|
|
case "squad":
|
|
return task.SquadID == issue.AssigneeID
|
|
}
|
|
return false
|
|
}
|
|
|
|
// stepHandlerName describes who a step hands the issue to, for briefs.
|
|
func (h *Handler) stepHandlerName(ctx context.Context, issue db.Issue, project db.Project, step issueworkflow.Step) string {
|
|
t, id, ok := issueworkflow.ResolveHandler(step, project, issue.CreatorType, issue.CreatorID)
|
|
if !ok {
|
|
switch step.Handler.Type {
|
|
case issueworkflow.HandlerProjectLead:
|
|
return "the project lead (none set)"
|
|
case issueworkflow.HandlerCreator:
|
|
return "the issue creator"
|
|
}
|
|
return ""
|
|
}
|
|
name := h.actorDisplayName(ctx, issue.WorkspaceID, t, id)
|
|
switch step.Handler.Type {
|
|
case issueworkflow.HandlerProjectLead:
|
|
return name + " (project lead)"
|
|
case issueworkflow.HandlerCreator:
|
|
return name + " (issue creator)"
|
|
}
|
|
return name
|
|
}
|