Files
multica/server/internal/handler/source_context.go
T
c7f259c70a revert(server): withdraw the reserved triage status key (#8450)
The `triage` status key was reserved so the server could read
`status = 'triage'` as "in Triage": migration 475 added a CHECK against it,
476 renamed any pre-existing custom `triage` status (and the issues and saved
views pointing at it), and issuestatus refused the key on every write path.

Migration 483 then moved Triage onto issue.triage_state, and nothing reads the
status that way any more. What is left is a rewrite of live customer data in
the deploy path that buys nothing, plus a name the product does not need to
own — users see a status's display name, not its key, so reserving the key
never stopped anyone from calling a custom status "Triage".

475-477 are emptied rather than reverted with a new migration: they have never
run outside development and CI, because releases deploy from tags and the
newest tag predates them. Migration 486 drops the constraint on databases that
did migrate off main, which would otherwise refuse a key the server now
accepts; 476's rename is not reversed, since the replacement is an ordinary
custom key by then. 478 loses the matching `triage` fast-path entry, where the
key falls through to the same answer either way.

The Go side goes with it: the Triage constant, ErrReservedStatus, the Resolve
refusal, IsBuiltIn's extra name, ErrStatusReservedForTriage and its two handler
mappings, the leading Triage rank in the issue sort, and the CLI and docs lines
that told users the key was reserved. Every triage_state path is untouched.

Tests now pin the reverted behavior: the emptied migrations leave a workspace's
custom `triage` status, issues and saved views exactly as they were, 486 drops
the barrier on a database that already has it, and `triage` works end to end as
an ordinary custom status key.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-15 18:12:35 +08:00

908 lines
38 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"path"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
agentpkg "github.com/multica-ai/multica/server/pkg/agent"
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"
)
type sourceContextStreamUploader interface {
UploadStream(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string, filename string) (string, error)
}
var errSourceContextResponseWritten = errors.New("source context response already written")
var errSourceContextBadRequest = errors.New("source context bad request")
func sourceContextBadRequest(message string) error {
return fmt.Errorf("%w: %s", errSourceContextBadRequest, message)
}
type sourceContextPreviewResponse struct {
SourceIssue service.SourceContextIssueSnapshot `json:"source_issue"`
CommentThread []service.SourceContextCommentSnapshot `json:"comment_thread"`
AnchorCommentID string `json:"anchor_comment_id"`
CaptureToken string `json:"capture_token"`
Limits service.SourceContextLimitUsage `json:"limits"`
}
type sourceContextAuthorState struct {
Type string `json:"type"`
ID string `json:"id"`
CapturedName string `json:"captured_name"`
CurrentName *string `json:"current_name,omitempty"`
State string `json:"state"`
}
type sourceContextCurrentSource struct {
IssueID string `json:"issue_id"`
AnchorCommentID string `json:"anchor_comment_id"`
Identifier string `json:"identifier"`
}
type sourceContextDetailResponse struct {
ID string `json:"id"`
Version int16 `json:"version"`
Usage string `json:"usage"`
CapturedAt string `json:"captured_at"`
DisplayState string `json:"display_state"`
SourceIssueState string `json:"source_issue_state"`
CommentThreadState string `json:"comment_thread_state"`
AnchorCommentState string `json:"anchor_comment_state"`
CanOpenCurrentSource bool `json:"can_open_current_source"`
ChangeReasons []string `json:"change_reasons"`
ChangeDetails sourceContextChangeDetails `json:"change_details"`
Snapshot service.SourceContextSnapshot `json:"snapshot"`
CurrentSource *sourceContextCurrentSource `json:"current_source,omitempty"`
SourceAuthorState []sourceContextAuthorState `json:"source_author_state"`
}
type sourceContextChangeDetails struct {
ChangedCommentIDs []string `json:"changed_comment_ids"`
AddedComments []service.SourceContextCommentSnapshot `json:"added_comments,omitempty"`
RemovedCommentIDs []string `json:"removed_comment_ids,omitempty"`
DescriptionAttachmentChanges []sourceContextDescriptionAttachmentChange `json:"description_attachment_changes"`
}
type sourceContextDescriptionAttachmentChange struct {
Kind string `json:"kind"`
AttachmentID string `json:"attachment_id"`
Filename string `json:"filename"`
PreviousFilename string `json:"previous_filename,omitempty"`
}
const (
sourceContextChangeIssueTitle = "issue_title"
sourceContextChangeIssueDescription = "issue_description"
sourceContextChangeIssueDescriptionAttachments = "issue_description_attachments"
sourceContextChangeCommentThread = "comment_thread"
)
type sourceContextComparableAttachment struct {
ID string
Filename string
ContentType string
SizeBytes int64
}
type sourceContextComparableThreadNode struct {
ID string
ParentID *string
Type string
}
func comparableSourceContextAttachments(items []service.SourceContextAttachment) []sourceContextComparableAttachment {
result := make([]sourceContextComparableAttachment, 0, len(items))
for _, item := range items {
id := item.ID
if item.SourceAttachmentID != "" {
id = item.SourceAttachmentID
}
result = append(result, sourceContextComparableAttachment{
ID: id, Filename: item.Filename, ContentType: item.ContentType, SizeBytes: item.SizeBytes,
})
}
return result
}
func jsonEqual(a, b any) bool {
left, leftErr := json.Marshal(a)
right, rightErr := json.Marshal(b)
return leftErr == nil && rightErr == nil && string(left) == string(right)
}
type sourceContextIssueChanges struct {
Reasons []string
DescriptionAttachmentChanges []sourceContextDescriptionAttachmentChange
}
func sourceContextIssueChangeDetails(captured, current service.SourceContextIssueSnapshot) sourceContextIssueChanges {
reasons := make([]string, 0, 3)
if captured.Title != current.Title {
reasons = append(reasons, sourceContextChangeIssueTitle)
}
capturedBody, capturedReferences := sourceContextDescriptionProjection(captured.Description, captured.Attachments, current.Attachments)
currentBody, currentReferences := sourceContextDescriptionProjection(current.Description, captured.Attachments, current.Attachments)
if capturedBody != currentBody {
reasons = append(reasons, sourceContextChangeIssueDescription)
}
attachmentChanges := sourceContextDescriptionAttachmentChanges(capturedReferences, currentReferences)
if len(attachmentChanges) > 0 {
reasons = append(reasons, sourceContextChangeIssueDescriptionAttachments)
}
return sourceContextIssueChanges{Reasons: reasons, DescriptionAttachmentChanges: attachmentChanges}
}
func sourceContextIssueChangeReasons(captured, current service.SourceContextIssueSnapshot) []string {
return sourceContextIssueChangeDetails(captured, current).Reasons
}
type sourceContextThreadChanges struct {
Reasons []string
ChangedCommentIDs []string
AddedComments []service.SourceContextCommentSnapshot
RemovedCommentIDs []string
}
func sourceContextThreadChangeDetails(captured, current service.SourceContextSnapshot) sourceContextThreadChanges {
capturedThread := make([]sourceContextComparableThreadNode, 0, len(captured.CommentThread))
currentThread := make([]sourceContextComparableThreadNode, 0, len(current.CommentThread))
capturedComments := make(map[string]service.SourceContextCommentSnapshot, len(captured.CommentThread))
currentComments := make(map[string]service.SourceContextCommentSnapshot, len(current.CommentThread))
// A tombstone only holds its replies' place, so it compares as absent: a
// comment deleted after capture reads as removed, not as emptied.
for _, comment := range captured.CommentThread {
if comment.Deleted {
continue
}
capturedThread = append(capturedThread, sourceContextComparableThreadNode{ID: comment.ID, ParentID: comment.ParentID, Type: comment.Type})
capturedComments[comment.ID] = comment
}
for _, comment := range current.CommentThread {
if comment.Deleted {
continue
}
currentThread = append(currentThread, sourceContextComparableThreadNode{ID: comment.ID, ParentID: comment.ParentID, Type: comment.Type})
currentComments[comment.ID] = comment
}
threadChanged := captured.AnchorCommentID != current.AnchorCommentID || !jsonEqual(capturedThread, currentThread)
changedCommentIDs := make([]string, 0)
for _, capturedComment := range captured.CommentThread {
if capturedComment.Deleted {
continue
}
currentComment, exists := currentComments[capturedComment.ID]
if !exists {
continue
}
contentChanged := sourceContextMarkdownContentProjection(
capturedComment.Content,
capturedComment.Attachments,
currentComment.Attachments,
) != sourceContextMarkdownContentProjection(
currentComment.Content,
capturedComment.Attachments,
currentComment.Attachments,
)
attachmentsChanged := !jsonEqual(
comparableSourceContextAttachments(capturedComment.Attachments),
comparableSourceContextAttachments(currentComment.Attachments),
)
structureChanged := capturedComment.Type != currentComment.Type || !jsonEqual(capturedComment.ParentID, currentComment.ParentID)
if contentChanged || attachmentsChanged || structureChanged {
changedCommentIDs = append(changedCommentIDs, capturedComment.ID)
threadChanged = true
}
}
reasons := make([]string, 0, 1)
if threadChanged {
reasons = append(reasons, sourceContextChangeCommentThread)
}
addedComments := make([]service.SourceContextCommentSnapshot, 0)
for _, currentComment := range current.CommentThread {
if currentComment.Deleted {
continue
}
if _, exists := capturedComments[currentComment.ID]; !exists {
addedComments = append(addedComments, currentComment)
}
}
removedCommentIDs := make([]string, 0)
for _, capturedComment := range captured.CommentThread {
if capturedComment.Deleted {
continue
}
if _, exists := currentComments[capturedComment.ID]; !exists {
removedCommentIDs = append(removedCommentIDs, capturedComment.ID)
}
}
return sourceContextThreadChanges{
Reasons: reasons, ChangedCommentIDs: changedCommentIDs,
AddedComments: addedComments, RemovedCommentIDs: removedCommentIDs,
}
}
func sourceContextThreadChangeReasons(captured, current service.SourceContextSnapshot) []string {
return sourceContextThreadChangeDetails(captured, current).Reasons
}
func (h *Handler) issueSourceContextDetail(ctx context.Context, issue db.Issue) (*sourceContextDetailResponse, error) {
row, err := h.Queries.GetIssueSourceContextByIssue(ctx, db.GetIssueSourceContextByIssueParams{WorkspaceID: issue.WorkspaceID, IssueID: issue.ID})
if err != nil {
return nil, err
}
var captured service.SourceContextSnapshot
if err := json.Unmarshal(row.Snapshot, &captured); err != nil {
return nil, fmt.Errorf("decode source context snapshot: %w", err)
}
response := &sourceContextDetailResponse{
ID: uuidToString(row.ID), Version: row.SnapshotVersion, Usage: service.SourceContextUsage,
CapturedAt: timestampToString(row.CapturedAt), DisplayState: "unchanged",
SourceIssueState: "unchanged", CommentThreadState: "unchanged", AnchorCommentState: "unavailable", ChangeReasons: []string{},
ChangeDetails: sourceContextChangeDetails{ChangedCommentIDs: []string{}, DescriptionAttachmentChanges: []sourceContextDescriptionAttachmentChange{}},
Snapshot: captured, SourceAuthorState: h.resolveSourceContextAuthors(ctx, issue.WorkspaceID, captured.CommentThread),
}
anchor, anchorErr := h.Queries.GetCommentInWorkspace(ctx, db.GetCommentInWorkspaceParams{
ID: row.AnchorCommentID, WorkspaceID: issue.WorkspaceID,
})
anchorExists := anchorErr == nil && anchor.Type == "comment" && !anchor.DeletedAt.Valid
if anchorExists {
response.AnchorCommentState = "available"
response.CurrentSource = &sourceContextCurrentSource{
IssueID: uuidToString(anchor.IssueID), AnchorCommentID: uuidToString(row.AnchorCommentID), Identifier: captured.SourceIssue.Identifier,
}
response.CanOpenCurrentSource = true
} else if errors.Is(anchorErr, pgx.ErrNoRows) || anchorErr == nil {
response.AnchorCommentState = "deleted"
}
var currentIssue service.SourceContextIssueSnapshot
var currentIssueErr error
if anchorExists {
currentIssue, _, currentIssueErr = service.BuildSourceIssueSnapshot(ctx, h.Queries, issue.WorkspaceID, anchor.IssueID)
} else {
currentIssue, _, currentIssueErr = service.BuildSourceIssueSnapshot(ctx, h.Queries, issue.WorkspaceID, row.SourceIssueID)
}
switch {
case currentIssueErr == nil:
issueChanges := sourceContextIssueChangeDetails(captured.SourceIssue, currentIssue)
response.ChangeDetails.DescriptionAttachmentChanges = issueChanges.DescriptionAttachmentChanges
response.ChangeReasons = append(response.ChangeReasons, issueChanges.Reasons...)
if len(response.ChangeReasons) > 0 {
response.SourceIssueState = "changed"
}
if anchorExists {
response.CurrentSource.Identifier = currentIssue.Identifier
}
case errors.Is(currentIssueErr, service.ErrSourceIssueDeleted):
response.SourceIssueState = "deleted"
response.CanOpenCurrentSource = false
default:
response.SourceIssueState = "unavailable"
}
current, buildErr := service.BuildSourceContext(ctx, h.Queries, issue.WorkspaceID, row.AnchorCommentID)
switch {
case buildErr == nil || (errors.Is(buildErr, service.ErrSourceContextTooLarge) && current.Snapshot.AnchorCommentID != ""):
threadChanges := sourceContextThreadChangeDetails(captured, current.Snapshot)
response.ChangeDetails.ChangedCommentIDs = threadChanges.ChangedCommentIDs
response.ChangeDetails.AddedComments = threadChanges.AddedComments
response.ChangeDetails.RemovedCommentIDs = threadChanges.RemovedCommentIDs
response.ChangeReasons = append(response.ChangeReasons, threadChanges.Reasons...)
if len(threadChanges.Reasons) > 0 {
response.CommentThreadState = "changed"
}
case errors.Is(buildErr, service.ErrAnchorCommentDeleted):
response.AnchorCommentState = "deleted"
response.CommentThreadState = "changed"
response.CanOpenCurrentSource = false
response.CurrentSource = nil
currentThread, threadErr := service.BuildSourceContextThreadAtCaptureBoundary(ctx, h.Queries, issue.WorkspaceID, captured)
if threadErr == nil {
currentSnapshot := captured
currentSnapshot.CommentThread = currentThread
threadChanges := sourceContextThreadChangeDetails(captured, currentSnapshot)
response.ChangeDetails.ChangedCommentIDs = threadChanges.ChangedCommentIDs
response.ChangeDetails.AddedComments = threadChanges.AddedComments
response.ChangeDetails.RemovedCommentIDs = threadChanges.RemovedCommentIDs
response.ChangeReasons = append(response.ChangeReasons, threadChanges.Reasons...)
} else {
response.ChangeReasons = append(response.ChangeReasons, sourceContextChangeCommentThread)
response.ChangeDetails.RemovedCommentIDs = []string{captured.AnchorCommentID}
}
case errors.Is(buildErr, service.ErrSourceIssueDeleted):
response.SourceIssueState = "deleted"
response.CommentThreadState = "unavailable"
response.CanOpenCurrentSource = false
response.CurrentSource = nil
case errors.Is(buildErr, service.ErrSourceContextInvalid) && anchorExists:
response.CommentThreadState = "changed"
response.ChangeReasons = append(response.ChangeReasons, sourceContextChangeCommentThread)
default:
response.CommentThreadState = "unavailable"
}
if response.SourceIssueState == "deleted" {
response.DisplayState = "deleted"
} else if response.SourceIssueState == "unavailable" || response.CommentThreadState == "unavailable" {
response.DisplayState = "unavailable"
} else if response.SourceIssueState != "unchanged" || response.CommentThreadState != "unchanged" {
response.DisplayState = "changed"
}
return response, nil
}
func (h *Handler) resolveSourceContextAuthors(ctx context.Context, workspaceID pgtype.UUID, comments []service.SourceContextCommentSnapshot) []sourceContextAuthorState {
seen := make(map[string]struct{})
states := make([]sourceContextAuthorState, 0)
for _, comment := range comments {
key := comment.Author.Type + ":" + comment.Author.ID
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
state := sourceContextAuthorState{Type: comment.Author.Type, ID: comment.Author.ID, CapturedName: comment.Author.Name, State: "unavailable"}
authorID, parseErr := util.ParseUUID(comment.Author.ID)
if parseErr != nil {
states = append(states, state)
continue
}
switch comment.Author.Type {
case "member":
if _, err := h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{UserID: authorID, WorkspaceID: workspaceID}); errors.Is(err, pgx.ErrNoRows) {
state.State = "no_longer_in_workspace"
} else if err == nil {
user, userErr := h.Queries.GetUser(ctx, authorID)
if userErr != nil {
states = append(states, state)
continue
}
state.CurrentName = &user.Name
if user.Name == comment.Author.Name {
state.State = "unchanged"
} else {
state.State = "renamed"
}
}
case "agent":
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{ID: authorID, WorkspaceID: workspaceID})
if errors.Is(err, pgx.ErrNoRows) {
state.State = "deleted_agent"
} else if err == nil {
state.CurrentName = &agent.Name
switch {
case agent.ArchivedAt.Valid:
state.State = "archived"
case agent.Name != comment.Author.Name:
state.State = "renamed"
default:
state.State = "unchanged"
}
}
}
states = append(states, state)
}
return states
}
func (h *Handler) PreviewCommentSubIssue(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return
}
anchorCommentID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "commentId"), "comment_id")
if !ok {
return
}
build, err := service.BuildSourceContext(r.Context(), h.Queries, wsUUID, anchorCommentID)
if err != nil {
h.writeSourceContextError(w, err, build.Limits)
return
}
writeJSON(w, http.StatusOK, sourceContextPreviewResponse{
SourceIssue: build.Snapshot.SourceIssue, CommentThread: build.Snapshot.CommentThread,
AnchorCommentID: build.Snapshot.AnchorCommentID, CaptureToken: build.Token, Limits: build.Limits,
})
}
type createCommentSubIssueRequest struct {
Mode string `json:"mode"`
CaptureToken string `json:"capture_token"`
Issue *CreateIssueRequest `json:"issue,omitempty"`
QuickCreate *QuickCreateIssueRequest `json:"quick_create,omitempty"`
}
func (h *Handler) CreateCommentSubIssue(w http.ResponseWriter, r *http.Request) {
var req createCommentSubIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user_id")
if !ok {
return
}
anchorCommentID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "commentId"), "comment_id")
if !ok {
return
}
wantDigest, tokenIssueID, validToken := service.ParseSourceContextToken(strings.TrimSpace(req.CaptureToken))
if !validToken {
writeJSON(w, http.StatusConflict, map[string]any{"code": "source_context_changed", "error": "source context preview is invalid; refresh and try again"})
return
}
build, err := service.BuildSourceContext(r.Context(), h.Queries, wsUUID, anchorCommentID)
if errors.Is(err, service.ErrAnchorCommentDeleted) {
if _, issueErr := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ID: tokenIssueID, WorkspaceID: wsUUID}); errors.Is(issueErr, pgx.ErrNoRows) {
err = service.ErrSourceIssueDeleted
}
}
if err != nil {
h.writeSourceContextError(w, err, build.Limits)
return
}
if build.Snapshot.SourceIssue.ID != util.UUIDToString(tokenIssueID) || build.Digest != wantDigest {
h.writeSourceContextError(w, service.ErrSourceContextChanged, build.Limits)
return
}
var preparedAgent *preparedAgentCommentSubIssue
switch req.Mode {
case "manual":
if req.Issue == nil {
writeError(w, http.StatusBadRequest, "issue is required for manual mode")
return
}
if strings.TrimSpace(req.Issue.Title) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"code": "invalid_request", "error": "title is required"})
return
}
case "agent":
if req.QuickCreate == nil {
writeError(w, http.StatusBadRequest, "quick_create is required for agent mode")
return
}
if err := service.ValidateSourceContextAgentInput(build, userUUID, strings.TrimSpace(req.QuickCreate.Prompt)); err != nil {
h.writeSourceContextError(w, err, build.Limits)
return
}
preparedAgent, err = h.prepareAgentCommentSubIssue(w, r, wsUUID, *req.QuickCreate)
if err != nil {
if !errors.Is(err, errSourceContextResponseWritten) {
h.writeSourceContextError(w, err, service.SourceContextLimitUsage{})
}
return
}
default:
writeError(w, http.StatusBadRequest, "mode must be manual or agent")
return
}
// Reject a full workspace before cloning source attachments. This early
// check avoids expensive work; the TaskService enqueue preflight and final
// transactional issue admission remain necessary because this is not a
// capacity reservation.
if err := service.CheckIssueCreateCapacity(r.Context(), h.Queries, h.Entitlements, wsUUID); err != nil {
if !writeIssueLimitReached(w, err) {
slog.Warn("source context issue-capacity preflight failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to check issue capacity")
}
return
}
contextID := dbid.NewV7()
capture, objectKeys, err := h.cloneSourceContext(r.Context(), wsUUID, userUUID, contextID, build)
if err != nil {
slog.Warn("source context capture failed", append(sourceContextAuditAttrs(r, contextID, build.Snapshot.SourceIssue.ID, build.Snapshot.AnchorCommentID, userUUID, req.Mode, build.Limits, "failed"), "error", err)...)
writeJSON(w, http.StatusBadGateway, map[string]any{"code": "source_context_attachment_copy_failed", "error": "failed to copy source attachments"})
return
}
cleanup := func() {
for _, key := range objectKeys {
h.Storage.Delete(context.Background(), key)
}
}
cleanupIfUnpersisted := func() {
_, lookupErr := h.Queries.GetIssueSourceContextByID(r.Context(), db.GetIssueSourceContextByIDParams{
WorkspaceID: wsUUID, ID: capture.ID,
})
switch {
case errors.Is(lookupErr, pgx.ErrNoRows):
cleanup()
case lookupErr != nil:
// A commit error can be ambiguous. Never delete an object while DB
// ownership is also uncertain; the durable intent reconciler performs
// the same reference check before deleting it.
slog.Warn("source context compensation deferred after ownership lookup failed", append(sourceContextAuditAttrs(r, capture.ID, capture.Snapshot.SourceIssue.ID, capture.Snapshot.AnchorCommentID, userUUID, req.Mode, build.Limits, "deferred_cleanup"), "error", lookupErr)...)
}
}
switch req.Mode {
case "manual":
if err := h.createManualCommentSubIssue(w, r, wsUUID, userUUID, *req.Issue, capture, build.Limits); err != nil {
cleanupIfUnpersisted()
slog.Warn("source context capture failed", append(sourceContextAuditAttrs(r, capture.ID, capture.Snapshot.SourceIssue.ID, capture.Snapshot.AnchorCommentID, userUUID, req.Mode, build.Limits, "failed"), "error", err)...)
if !errors.Is(err, errSourceContextResponseWritten) {
h.writeSourceContextError(w, err, service.SourceContextLimitUsage{})
}
}
case "agent":
if err := h.createAgentCommentSubIssue(w, r, wsUUID, userUUID, *preparedAgent, capture, build.Limits); err != nil {
cleanupIfUnpersisted()
slog.Warn("source context capture failed", append(sourceContextAuditAttrs(r, capture.ID, capture.Snapshot.SourceIssue.ID, capture.Snapshot.AnchorCommentID, userUUID, req.Mode, build.Limits, "failed"), "error", err)...)
if !errors.Is(err, errSourceContextResponseWritten) {
h.writeSourceContextError(w, err, service.SourceContextLimitUsage{})
}
}
}
}
func sourceContextAuditAttrs(r *http.Request, contextID pgtype.UUID, sourceIssueID, anchorCommentID string, capturedBy pgtype.UUID, mode string, limits service.SourceContextLimitUsage, result string) []any {
return append(logger.RequestAttrs(r),
"source_context_id", util.UUIDToString(contextID),
"source_issue_id", sourceIssueID,
"anchor_comment_id", anchorCommentID,
"captured_by_user_id", util.UUIDToString(capturedBy),
"mode", mode,
"comment_count", limits.CommentCount,
"text_bytes", limits.TextBytes,
"attachment_count", limits.AttachmentCount,
"attachment_bytes", limits.AttachmentBytes,
"result", result,
)
}
func (h *Handler) cloneSourceContext(ctx context.Context, workspaceID, userID, contextID pgtype.UUID, build service.SourceContextBuild) (service.SourceContextCapture, []string, error) {
if len(build.Rows) == 0 {
capture, err := service.PrepareSourceContextCapture(build, contextID, workspaceID, userID, time.Now(), nil)
return capture, nil, err
}
uploader, ok := h.Storage.(sourceContextStreamUploader)
if h.Storage == nil || !ok {
return service.SourceContextCapture{}, nil, errors.New("storage does not support streaming upload")
}
clones := make([]service.SourceContextClone, 0, len(build.Rows))
keys := make([]string, 0, len(build.Rows))
cleanup := func() {
for _, key := range keys {
h.Storage.Delete(context.Background(), key)
}
}
for _, source := range build.Rows {
sourceKey := h.Storage.KeyFromURL(source.Url)
reader, err := h.Storage.GetReader(ctx, sourceKey)
if err != nil {
cleanup()
return service.SourceContextCapture{}, nil, err
}
cloneID := dbid.NewV7()
cloneKey := fmt.Sprintf("workspaces/%s/source-context/%s%s", util.UUIDToString(workspaceID), util.UUIDToString(cloneID), path.Ext(source.Filename))
if _, err := h.Queries.RecordSourceContextObjectIntent(ctx, db.RecordSourceContextObjectIntentParams{
StorageKey: cloneKey, WorkspaceID: workspaceID, SourceContextID: contextID,
AttachmentID: cloneID, ObjectUrl: h.Storage.ObjectURL(cloneKey),
}); err != nil {
_ = reader.Close()
cleanup()
return service.SourceContextCapture{}, nil, fmt.Errorf("record source context object intent: %w", err)
}
url, uploadErr := uploader.UploadStream(ctx, cloneKey, reader, source.SizeBytes, source.ContentType, source.Filename)
closeErr := reader.Close()
if uploadErr != nil || closeErr != nil {
cleanup()
if uploadErr != nil {
return service.SourceContextCapture{}, nil, uploadErr
}
return service.SourceContextCapture{}, nil, closeErr
}
keys = append(keys, cloneKey)
clones = append(clones, service.SourceContextClone{ID: cloneID, Filename: source.Filename, URL: url, ContentType: source.ContentType, SizeBytes: source.SizeBytes})
}
capture, err := service.PrepareSourceContextCapture(build, contextID, workspaceID, userID, time.Now(), clones)
if err != nil {
cleanup()
return service.SourceContextCapture{}, nil, err
}
return capture, keys, nil
}
func (h *Handler) createManualCommentSubIssue(w http.ResponseWriter, r *http.Request, workspaceID, userID pgtype.UUID, input CreateIssueRequest, capture service.SourceContextCapture, limits service.SourceContextLimitUsage) error {
title := strings.TrimSpace(input.Title)
if title == "" {
return sourceContextBadRequest("title is required")
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "todo"
}
var ok bool
status, ok = h.resolveIssueStatusKey(w, r, workspaceID, status)
if !ok {
return errSourceContextResponseWritten
}
priority := strings.TrimSpace(input.Priority)
if priority == "" {
priority = "none"
}
if !validateIssueEnum(w, "priority", priority, validIssuePriorities) {
return errSourceContextResponseWritten
}
var assigneeType pgtype.Text
var assigneeID pgtype.UUID
if input.AssigneeType != nil && strings.TrimSpace(*input.AssigneeType) != "" {
assigneeType = pgtype.Text{String: strings.TrimSpace(*input.AssigneeType), Valid: true}
}
if input.AssigneeID != nil && strings.TrimSpace(*input.AssigneeID) != "" {
parsed, err := util.ParseUUID(strings.TrimSpace(*input.AssigneeID))
if err != nil {
return sourceContextBadRequest("invalid assignee_id")
}
assigneeID = parsed
}
if code, message := h.validateAssigneePair(r.Context(), r, util.UUIDToString(workspaceID), assigneeType, assigneeID); code != 0 {
writeError(w, code, message)
return errSourceContextResponseWritten
}
var projectID pgtype.UUID
if input.ProjectID != nil && strings.TrimSpace(*input.ProjectID) != "" {
parsed, err := util.ParseUUID(strings.TrimSpace(*input.ProjectID))
if err != nil {
return sourceContextBadRequest("invalid project_id")
}
projectID = parsed
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, input.AttachmentIDs, "attachment_ids")
if !ok {
return errSourceContextResponseWritten
}
labelIDs, ok := parseUUIDSliceOrBadRequest(w, input.LabelIDs, "label_ids")
if !ok {
return errSourceContextResponseWritten
}
var startDate, dueDate pgtype.Date
if input.StartDate != nil && *input.StartDate != "" {
parsed, err := util.ParseCalendarDate(*input.StartDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
return errSourceContextResponseWritten
}
startDate = parsed
}
if input.DueDate != nil && *input.DueDate != "" {
parsed, err := util.ParseCalendarDate(*input.DueDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
return errSourceContextResponseWritten
}
dueDate = parsed
}
var stage pgtype.Int4
if input.Stage != nil {
if *input.Stage < 1 {
writeError(w, http.StatusBadRequest, "stage must be at least 1")
return errSourceContextResponseWritten
}
stage = pgtype.Int4{Int32: *input.Stage, Valid: true}
}
prefix := h.getIssuePrefix(r.Context(), workspaceID)
result, err := h.IssueService.Create(r.Context(), service.IssueCreateParams{
WorkspaceID: workspaceID, Title: title, Description: ptrToText(input.Description), Status: status, Priority: priority,
AssigneeType: assigneeType, AssigneeID: assigneeID, CreatorType: "member", CreatorID: userID,
ParentIssueID: capture.SourceIssueID, ProjectID: projectID, StartDate: startDate, DueDate: dueDate,
AttachmentIDs: attachmentIDs, LabelIDs: labelIDs, Stage: stage,
AllowDuplicate: input.AllowDuplicate, SourceContext: &capture,
}, service.IssueCreateOpts{
ActorID: util.UUIDToString(userID),
BroadcastPayload: func(issue db.Issue, _ []db.Attachment, labels []db.IssueLabel) map[string]any {
response := issueToResponse(issue, prefix)
labelResponses := labelsToResponse(labels)
response.Labels = &labelResponses
return map[string]any{"issue": response}
},
})
if err != nil {
return err
}
response := issueToResponse(result.Issue, prefix)
h.fillStatusCategory(r.Context(), workspaceID, &response)
labelResponses := labelsToResponse(result.Labels)
response.Labels = &labelResponses
slog.Info("sub-issue created from captured comment", append(sourceContextAuditAttrs(r, capture.ID, capture.Snapshot.SourceIssue.ID, capture.Snapshot.AnchorCommentID, userID, "manual", limits, "success"), "target_issue_id", uuidToString(result.Issue.ID))...)
writeJSON(w, http.StatusCreated, response)
return nil
}
type preparedAgentCommentSubIssue struct {
agentID, squadID, runtimeID pgtype.UUID
prompt, priority, dueDate string
projectID pgtype.UUID
attachmentIDs []pgtype.UUID
}
func (h *Handler) prepareAgentCommentSubIssue(w http.ResponseWriter, r *http.Request, workspaceID pgtype.UUID, input QuickCreateIssueRequest) (*preparedAgentCommentSubIssue, error) {
prompt := strings.TrimSpace(input.Prompt)
if prompt == "" {
return nil, sourceContextBadRequest("prompt is required")
}
hasAgent := strings.TrimSpace(input.AgentID) != ""
hasSquad := strings.TrimSpace(input.SquadID) != ""
if hasAgent == hasSquad {
return nil, sourceContextBadRequest("exactly one of agent_id or squad_id is required")
}
priority := strings.ToLower(strings.TrimSpace(input.Priority))
if priority != "" && priority != "urgent" && priority != "high" && priority != "medium" && priority != "low" {
return nil, sourceContextBadRequest("invalid priority")
}
var agentID, squadID pgtype.UUID
if hasSquad {
parsed, err := util.ParseUUID(strings.TrimSpace(input.SquadID))
if err != nil {
return nil, sourceContextBadRequest("invalid squad_id")
}
squadID = parsed
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{ID: squadID, WorkspaceID: workspaceID})
if err != nil || squad.ArchivedAt.Valid {
return nil, sourceContextBadRequest("squad not found or archived")
}
agentID = squad.LeaderID
} else {
parsed, err := util.ParseUUID(strings.TrimSpace(input.AgentID))
if err != nil {
return nil, sourceContextBadRequest("invalid agent_id")
}
agentID = parsed
}
if status, message := h.validateAssigneePair(r.Context(), r, util.UUIDToString(workspaceID), pgtype.Text{String: "agent", Valid: true}, agentID); status != 0 {
writeError(w, status, message)
return nil, errSourceContextResponseWritten
}
agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{ID: agentID, WorkspaceID: workspaceID})
if err != nil {
return nil, sourceContextBadRequest("agent not found")
}
verdict, err := service.AgentReadiness(r.Context(), h.runtimeLookup(obsmetrics.RuntimeLookupSourceSourceContext), agent)
if err != nil {
return nil, err
}
if !verdict.Ready() {
writeAgentUnavailable(w, verdict.Detail, verdict.Reason)
return nil, errSourceContextResponseWritten
}
if status, payload := h.checkQuickCreateDaemonVersion(r.Context(), obsmetrics.RuntimeLookupSourceSourceContext, agent.RuntimeID); status != 0 {
writeJSON(w, status, payload)
return nil, errSourceContextResponseWritten
}
runtime, err := h.getAgentRuntime(r.Context(), obsmetrics.RuntimeLookupSourceSourceContext, agent.RuntimeID)
if err != nil || !runtimeHasCapability(runtime.Metadata, protocol.DaemonCapabilitySourceContextQuickCreateV1) {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"code": "source_context_quick_create_unsupported", "error": "selected agent runtime must be updated before using captured context"})
return nil, errSourceContextResponseWritten
}
dueDate := strings.TrimSpace(input.DueDate)
if dueDate != "" {
parsed, err := util.ParseCalendarDate(dueDate)
if err != nil {
return nil, sourceContextBadRequest("invalid due_date")
}
dueDate = parsed.Time.Format("2006-01-02")
}
if priority != "" || dueDate != "" {
if status, payload := h.checkQuickCreateDaemonVersionAtLeast(r.Context(), obsmetrics.RuntimeLookupSourceSourceContext, agent.RuntimeID, agentpkg.MinQuickCreateFieldsCLIVersion); status != 0 {
writeJSON(w, status, payload)
return nil, errSourceContextResponseWritten
}
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, input.AttachmentIDs, "attachment_ids")
if !ok {
return nil, errSourceContextResponseWritten
}
var projectID pgtype.UUID
if strings.TrimSpace(input.ProjectID) != "" {
parsed, err := util.ParseUUID(strings.TrimSpace(input.ProjectID))
if err != nil {
return nil, sourceContextBadRequest("invalid project_id")
}
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{ID: parsed, WorkspaceID: workspaceID}); err != nil {
return nil, sourceContextBadRequest("project not found")
}
projectID = parsed
}
return &preparedAgentCommentSubIssue{
agentID: agentID, squadID: squadID, runtimeID: agent.RuntimeID,
prompt: prompt, priority: priority, dueDate: dueDate,
projectID: projectID, attachmentIDs: attachmentIDs,
}, nil
}
func (h *Handler) createAgentCommentSubIssue(w http.ResponseWriter, r *http.Request, workspaceID, userID pgtype.UUID, prepared preparedAgentCommentSubIssue, capture service.SourceContextCapture, limits service.SourceContextLimitUsage) error {
// Recheck the capability after potentially long streaming copies. A runtime
// can re-register during the copy; the final enqueue must still fail closed.
runtime, err := h.getAgentRuntime(r.Context(), obsmetrics.RuntimeLookupSourceSourceContext, prepared.runtimeID)
if err != nil || !runtimeHasCapability(runtime.Metadata, protocol.DaemonCapabilitySourceContextQuickCreateV1) {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"code": "source_context_quick_create_unsupported", "error": "selected agent runtime must be updated before using captured context"})
return errSourceContextResponseWritten
}
task, err := h.TaskService.EnqueueQuickCreateTaskWithSourceContext(r.Context(), workspaceID, userID, prepared.agentID, prepared.squadID, prepared.prompt, prepared.priority, prepared.dueDate, prepared.projectID, capture.SourceIssueID, prepared.attachmentIDs, capture)
if err != nil {
return err
}
slog.Info("sub-issue quick-create enqueued from captured comment", append(sourceContextAuditAttrs(r, capture.ID, capture.Snapshot.SourceIssue.ID, capture.Snapshot.AnchorCommentID, userID, "agent", limits, "success"), "task_id", uuidToString(task.ID))...)
writeJSON(w, http.StatusAccepted, QuickCreateIssueResponse{TaskID: uuidToString(task.ID)})
return nil
}
func (h *Handler) writeSourceContextError(w http.ResponseWriter, err error, limits service.SourceContextLimitUsage) {
if writeIssueLimitReached(w, err) {
return
}
status := http.StatusInternalServerError
code := "source_context_capture_failed"
message := "failed to capture source context"
switch {
case errors.Is(err, service.ErrSourceContextChanged):
status, code = http.StatusConflict, "source_context_changed"
message = err.Error()
case errors.Is(err, service.ErrAnchorCommentDeleted):
status, code = http.StatusConflict, "anchor_comment_deleted"
message = err.Error()
case errors.Is(err, service.ErrSourceIssueDeleted):
status, code = http.StatusConflict, "source_issue_deleted"
message = err.Error()
case errors.Is(err, service.ErrSourceContextInvalid):
status, code = http.StatusConflict, "source_context_invalid_path"
message = err.Error()
case errors.Is(err, service.ErrSourceContextTooLarge):
status, code = http.StatusUnprocessableEntity, "source_context_too_large"
message = err.Error()
case errors.Is(err, service.ErrSourceContextAlreadyAttached):
status, code = http.StatusConflict, "source_context_already_attached"
message = err.Error()
case errors.Is(err, service.ErrActiveDuplicate):
status, code = http.StatusConflict, "active_duplicate_issue"
message = err.Error()
case errors.Is(err, service.ErrParentIssueNotFound), errors.Is(err, service.ErrProjectNotFound):
status = http.StatusBadRequest
message = err.Error()
case errors.Is(err, errSourceContextBadRequest):
status, code = http.StatusBadRequest, "invalid_request"
message = err.Error()
case err == nil:
return
}
payload := map[string]any{"code": code, "error": message}
if errors.Is(err, service.ErrSourceContextTooLarge) {
payload["limits"] = limits
}
writeJSON(w, status, payload)
}