Files
multica/server/internal/service/source_context.go
Bohan Jiang a9e82c7973 MUL-7282: fix(comments): delete only the selected comment and keep its replies (#8323)
Deleting a comment removed every reply under it, through the parent_id ON DELETE CASCADE. Now delete removes only that comment: one with replies becomes a tombstone (deleted_at set, body/attachments/reactions/resolution cleared) so the replies keep their parent, and one without replies is removed along with any tombstone ancestor left empty.

Tombstones are read-only and are never agent input. Every write to a comment's children takes its owners first (issue -> comment -> child), and a comment commits together with the attachments it was posted with. Clients only promise that replies are kept when the server declares comment_delete_keep_replies_supported, and then delete through DELETE /api/comments/{id}/keep-replies, which older servers do not route.
2026-09-13 19:13:43 +08:00

812 lines
33 KiB
Go

package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/dbid"
)
const (
SourceContextVersion = int16(1)
SourceContextUsage = "read_only_historical_background"
SourceContextMaxComments = 256
// SourceContextMaxTextBytes preserves the general/manual capture limit.
SourceContextMaxTextBytes = 1 << 20
// SourceContextMaxAgentSnapshotBytes is lower because agent mode quotes the
// immutable snapshot into its first user prompt.
SourceContextMaxAgentSnapshotBytes = 64 << 10
// SourceContextMaxAgentInputBytes includes the snapshot plus the new
// instruction. A tokenizer cannot emit more tokens than the input has bytes;
// keeping the dynamic input below 72 KiB leaves more than 100k tokens for the
// stable quick-create wrapper, runtime brief, system prompt, and tools.
SourceContextMaxAgentInputBytes = 72 << 10
SourceContextMaxAgentPromptBytes = 96 << 10
SourceContextMaxAttachments = 100
SourceContextMaxAttachmentBytes = int64(500 << 20)
)
const (
// sourceContextObjectDeleteTimeout bounds a single object-store delete.
// Cleanup runs on its own sweeper goroutine, so an unhealthy endpoint can no
// longer starve runtime and task recovery, but without a per-object deadline
// one hung delete would still hold a whole cleanup round open.
sourceContextObjectDeleteTimeout = 30 * time.Second
// sourceContextIntentReleaseTimeout bounds the retry-backoff write that
// follows a failed delete. It runs detached from the caller's round budget:
// when the budget is what cut the delete short, a cancelled release would
// leave the intent immediately claimable and every later round would retry
// the same unhealthy object ahead of the ones queued behind it.
sourceContextIntentReleaseTimeout = 5 * time.Second
)
var (
ErrAnchorCommentDeleted = errors.New("anchor comment deleted")
ErrSourceIssueDeleted = errors.New("source issue deleted")
ErrSourceContextChanged = errors.New("source context changed")
ErrSourceContextInvalid = errors.New("source context invalid comment thread")
ErrSourceContextTooLarge = errors.New("source context too large")
ErrSourceContextRetryUnavailable = errors.New("source context retry unavailable")
)
func validateSourceContextAgentBytes(snapshotBytes int, prompt string) error {
if snapshotBytes < 0 || snapshotBytes > SourceContextMaxAgentSnapshotBytes || snapshotBytes+len(prompt) > SourceContextMaxAgentInputBytes {
return ErrSourceContextTooLarge
}
return nil
}
// ValidateSourceContextAgentInput keeps every accepted source-context
// quick-create within the prompt budget that the daemon regression test locks
// against its complete user-message wrapper. Project the persisted snapshot,
// including capture metadata, cloned attachment IDs, and rewritten URLs, so
// the preview payload cannot undercount what the daemon will actually quote.
// This runs before object cloning or task enqueue and has no external effects.
func ValidateSourceContextAgentInput(build SourceContextBuild, capturedBy pgtype.UUID, prompt string) error {
projectedCloneID := pgtype.UUID{Valid: true}
clones := make([]SourceContextClone, len(build.Rows))
for i := range clones {
clones[i].ID = projectedCloneID
}
// RFC3339Nano emits its longest representation when nanoseconds are nonzero.
projectedCapturedAt := time.Date(2000, time.January, 1, 0, 0, 0, 999999999, time.UTC)
projected, err := PrepareSourceContextCapture(
build,
pgtype.UUID{Valid: true},
pgtype.UUID{Valid: true},
capturedBy,
projectedCapturedAt,
clones,
)
if err != nil {
return err
}
payload, err := json.Marshal(projected.Snapshot)
if err != nil {
return fmt.Errorf("marshal projected source context: %w", err)
}
return validateSourceContextAgentBytes(len(payload), prompt)
}
type SourceContextLimitUsage struct {
CommentCount int `json:"comment_count"`
TextBytes int `json:"text_bytes"`
AttachmentCount int `json:"attachment_count"`
AttachmentBytes int64 `json:"attachment_bytes"`
}
type SourceContextAttachment struct {
ID string `json:"id"`
SourceAttachmentID string `json:"source_attachment_id,omitempty"`
OwnerType string `json:"owner_type"`
OwnerID string `json:"owner_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
}
type SourceContextAuthor struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
}
type SourceContextIssueSnapshot struct {
ID string `json:"id"`
Identifier string `json:"identifier"`
Number int32 `json:"number"`
Title string `json:"title"`
Description *string `json:"description"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Revision int64 `json:"revision"`
Attachments []SourceContextAttachment `json:"attachments"`
}
type SourceContextCommentSnapshot struct {
ID string `json:"id"`
ParentID *string `json:"parent_id"`
Type string `json:"type"`
Content string `json:"content"`
Author SourceContextAuthor `json:"author"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Revision int64 `json:"revision"`
Attachments []SourceContextAttachment `json:"attachments"`
// Deleted marks a tombstone: a comment deleted while it still had replies,
// kept in the thread only so those replies keep their parent. Its Content
// is empty. Omitted otherwise, so existing snapshot digests are unchanged.
Deleted bool `json:"deleted,omitempty"`
}
type SourceContextSnapshot struct {
Version int16 `json:"version,omitempty"`
CapturedByUserID string `json:"captured_by_user_id,omitempty"`
CapturedAt string `json:"captured_at,omitempty"`
SourceIssue SourceContextIssueSnapshot `json:"source_issue"`
CommentThread []SourceContextCommentSnapshot `json:"comment_thread"`
AnchorCommentID string `json:"anchor_comment_id"`
}
type SourceContextBuild struct {
Snapshot SourceContextSnapshot
Digest string
Token string
Limits SourceContextLimitUsage
Rows []db.Attachment
}
type SourceContextClone struct {
ID pgtype.UUID
Filename string
URL string
ContentType string
SizeBytes int64
}
type SourceContextCapture struct {
ID pgtype.UUID
WorkspaceID pgtype.UUID
SourceIssueID pgtype.UUID
AnchorCommentID pgtype.UUID
CapturedByUserID pgtype.UUID
CapturedAt time.Time
Snapshot SourceContextSnapshot
Digest string
Clones []SourceContextClone
}
func sourceContextTime(v pgtype.Timestamptz) string {
return v.Time.UTC().Format(time.RFC3339Nano)
}
func sourceContextText(v pgtype.Text) *string {
if !v.Valid {
return nil
}
s := v.String
return &s
}
func sourceContextAuthorName(ctx context.Context, q *db.Queries, workspaceID pgtype.UUID, authorType string, authorID pgtype.UUID) (string, error) {
switch authorType {
case "member":
user, err := q.GetUser(ctx, authorID)
if err != nil {
return "", fmt.Errorf("resolve source-context member author: %w", err)
}
return user.Name, nil
case "agent":
agent, err := q.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{ID: authorID, WorkspaceID: workspaceID})
if err != nil {
return "", fmt.Errorf("resolve source-context agent author: %w", err)
}
return agent.Name, nil
}
return "", fmt.Errorf("unsupported source-context author type %q", authorType)
}
func attachmentSnapshot(row db.Attachment, ownerType, ownerID string) SourceContextAttachment {
return SourceContextAttachment{
ID: util.UUIDToString(row.ID),
OwnerType: ownerType,
OwnerID: ownerID,
Filename: row.Filename,
ContentType: row.ContentType,
SizeBytes: row.SizeBytes,
CreatedAt: sourceContextTime(row.CreatedAt),
}
}
func sourceContextDigest(snapshot SourceContextSnapshot) (string, error) {
canonical := snapshot
// CommentThread is a slice, so copying the enclosing struct alone would keep
// sharing its backing array with the snapshot that will be persisted. The
// digest projection must never erase capture metadata from that snapshot.
canonical.CommentThread = append([]SourceContextCommentSnapshot(nil), snapshot.CommentThread...)
canonical.Version = 0
canonical.CapturedByUserID = ""
canonical.CapturedAt = ""
canonical.SourceIssue.UpdatedAt = ""
canonical.SourceIssue.Revision = 0
for i := range canonical.CommentThread {
// Display names are live identity metadata, not source content. A rename
// between preview and creation must not invalidate an otherwise identical
// capture; detail rendering reports the rename independently.
canonical.CommentThread[i].Author.Name = ""
canonical.CommentThread[i].UpdatedAt = ""
canonical.CommentThread[i].Revision = 0
}
payload, err := json.Marshal(canonical)
if err != nil {
return "", fmt.Errorf("marshal canonical source context: %w", err)
}
digestBytes := sha256.Sum256(payload)
return hex.EncodeToString(digestBytes[:]), nil
}
// BuildSourceIssueSnapshot builds the issue-owned portion of a source
// snapshot without requiring the anchor comment to still exist. Detail
// rendering uses it to report source_issue_state accurately after a source
// comment (or one of its ancestors) has been deleted.
func BuildSourceIssueSnapshot(ctx context.Context, q *db.Queries, workspaceID, issueID pgtype.UUID) (SourceContextIssueSnapshot, []db.Attachment, error) {
issue, err := q.GetIssueInWorkspace(ctx, db.GetIssueInWorkspaceParams{ID: issueID, WorkspaceID: workspaceID})
if errors.Is(err, pgx.ErrNoRows) {
return SourceContextIssueSnapshot{}, nil, ErrSourceIssueDeleted
}
if err != nil {
return SourceContextIssueSnapshot{}, nil, fmt.Errorf("load source issue: %w", err)
}
workspace, err := q.GetWorkspace(ctx, workspaceID)
if err != nil {
return SourceContextIssueSnapshot{}, nil, fmt.Errorf("load source workspace: %w", err)
}
attachments, err := q.ListSourceContextIssueAttachments(ctx, db.ListSourceContextIssueAttachmentsParams{IssueID: issue.ID, WorkspaceID: workspaceID})
if err != nil {
return SourceContextIssueSnapshot{}, nil, fmt.Errorf("load issue attachments: %w", err)
}
sort.Slice(attachments, func(i, j int) bool {
return util.UUIDToString(attachments[i].ID) < util.UUIDToString(attachments[j].ID)
})
attachmentSnapshots := make([]SourceContextAttachment, 0, len(attachments))
for _, attachment := range attachments {
attachmentSnapshots = append(attachmentSnapshots, attachmentSnapshot(attachment, "issue", util.UUIDToString(issue.ID)))
}
return SourceContextIssueSnapshot{
ID: util.UUIDToString(issue.ID), Identifier: workspace.IssuePrefix + "-" + fmt.Sprint(issue.Number), Number: issue.Number,
Title: issue.Title, Description: sourceContextText(issue.Description),
CreatedAt: sourceContextTime(issue.CreatedAt), UpdatedAt: sourceContextTime(issue.UpdatedAt), Revision: issue.Revision,
Attachments: attachmentSnapshots,
}, attachments, nil
}
func buildSourceContextCommentSnapshots(
ctx context.Context,
q *db.Queries,
workspaceID pgtype.UUID,
issueID pgtype.UUID,
historyRows []db.ListCommentThreadHistoryRow,
) ([]SourceContextCommentSnapshot, []db.Attachment, error) {
commentIDs := make([]pgtype.UUID, 0, len(historyRows))
for _, row := range historyRows {
commentIDs = append(commentIDs, row.ID)
}
commentAttachments := make([]db.Attachment, 0)
if len(commentIDs) > 0 {
var err error
commentAttachments, err = q.ListSourceContextCommentAttachments(ctx, db.ListSourceContextCommentAttachmentsParams{
WorkspaceID: workspaceID, IssueID: issueID, CommentIds: commentIDs,
})
if err != nil {
return nil, nil, fmt.Errorf("load comment attachments: %w", err)
}
}
sort.Slice(commentAttachments, func(i, j int) bool {
return util.UUIDToString(commentAttachments[i].ID) < util.UUIDToString(commentAttachments[j].ID)
})
byComment := make(map[string][]SourceContextAttachment)
for _, attachment := range commentAttachments {
commentID := util.UUIDToString(attachment.CommentID)
byComment[commentID] = append(byComment[commentID], attachmentSnapshot(attachment, "comment", commentID))
}
commentThread := make([]SourceContextCommentSnapshot, 0, len(historyRows))
for _, row := range historyRows {
commentID := util.UUIDToString(row.ID)
authorName, err := sourceContextAuthorName(ctx, q, workspaceID, row.AuthorType, row.AuthorID)
if err != nil {
return nil, nil, err
}
var parentID *string
if row.ParentID.Valid {
value := util.UUIDToString(row.ParentID)
parentID = &value
}
attachments := byComment[commentID]
if attachments == nil {
attachments = []SourceContextAttachment{}
}
commentThread = append(commentThread, SourceContextCommentSnapshot{
ID: commentID, ParentID: parentID, Type: row.Type, Content: row.Content,
Author: SourceContextAuthor{Type: row.AuthorType, ID: util.UUIDToString(row.AuthorID), Name: authorName},
CreatedAt: sourceContextTime(row.CreatedAt), UpdatedAt: sourceContextTime(row.UpdatedAt), Revision: row.Revision,
Attachments: attachments, Deleted: row.DeletedAt.Valid,
})
}
return commentThread, commentAttachments, nil
}
// BuildSourceContextThreadAtCaptureBoundary rebuilds the surviving portion of
// a captured thread when its anchor no longer exists. The immutable root and
// anchor timestamp preserve the original chronological boundary, allowing the
// detail response to classify changes to every captured node instead of only
// reporting the deleted anchor.
func BuildSourceContextThreadAtCaptureBoundary(
ctx context.Context,
q *db.Queries,
workspaceID pgtype.UUID,
captured SourceContextSnapshot,
) ([]SourceContextCommentSnapshot, error) {
if len(captured.CommentThread) == 0 || captured.CommentThread[0].ParentID != nil {
return nil, ErrSourceContextInvalid
}
var anchor *SourceContextCommentSnapshot
for i := range captured.CommentThread {
if captured.CommentThread[i].ID == captured.AnchorCommentID {
anchor = &captured.CommentThread[i]
break
}
}
if anchor == nil {
return nil, ErrSourceContextInvalid
}
rootID, err := util.ParseUUID(captured.CommentThread[0].ID)
if err != nil {
return nil, fmt.Errorf("parse captured thread root: %w", err)
}
issueID, err := util.ParseUUID(captured.SourceIssue.ID)
if err != nil {
return nil, fmt.Errorf("parse captured source issue: %w", err)
}
anchorID, err := util.ParseUUID(captured.AnchorCommentID)
if err != nil {
return nil, fmt.Errorf("parse captured anchor: %w", err)
}
anchorCreatedAt, err := time.Parse(time.RFC3339Nano, anchor.CreatedAt)
if err != nil {
return nil, fmt.Errorf("parse captured anchor timestamp: %w", err)
}
historyRows, err := q.ListCommentThreadHistory(ctx, db.ListCommentThreadHistoryParams{
RootID: rootID, WorkspaceID: workspaceID, IssueID: issueID,
AnchorCreatedAt: pgtype.Timestamptz{Time: anchorCreatedAt, Valid: true}, AnchorID: anchorID,
RowLimit: SourceContextMaxComments + 1,
})
if err != nil {
return nil, fmt.Errorf("load source thread at capture boundary: %w", err)
}
commentThread, _, err := buildSourceContextCommentSnapshots(ctx, q, workspaceID, issueID, historyRows)
return commentThread, err
}
func BuildSourceContext(ctx context.Context, q *db.Queries, workspaceID, anchorCommentID pgtype.UUID) (SourceContextBuild, error) {
anchor, err := q.GetCommentInWorkspace(ctx, db.GetCommentInWorkspaceParams{ID: anchorCommentID, WorkspaceID: workspaceID})
if errors.Is(err, pgx.ErrNoRows) {
return SourceContextBuild{}, ErrAnchorCommentDeleted
}
if err != nil {
return SourceContextBuild{}, fmt.Errorf("load anchor comment: %w", err)
}
if anchor.DeletedAt.Valid {
return SourceContextBuild{}, ErrAnchorCommentDeleted
}
if anchor.Type != "comment" {
return SourceContextBuild{}, ErrSourceContextInvalid
}
issueSnapshot, issueAttachments, err := BuildSourceIssueSnapshot(ctx, q, workspaceID, anchor.IssueID)
if err != nil {
return SourceContextBuild{}, err
}
ancestorRows, err := q.ListCommentAncestorPath(ctx, db.ListCommentAncestorPathParams{
CommentID: anchorCommentID, WorkspaceID: workspaceID, IssueID: anchor.IssueID,
})
if err != nil {
return SourceContextBuild{}, fmt.Errorf("load source path: %w", err)
}
if len(ancestorRows) == 0 || ancestorRows[len(ancestorRows)-1].ID != anchorCommentID || ancestorRows[0].ParentID.Valid {
return SourceContextBuild{}, ErrSourceContextInvalid
}
if len(ancestorRows) > SourceContextMaxComments {
return SourceContextBuild{Limits: SourceContextLimitUsage{CommentCount: len(ancestorRows)}}, ErrSourceContextTooLarge
}
seenAncestors := make(map[string]struct{}, len(ancestorRows))
for _, row := range ancestorRows {
id := util.UUIDToString(row.ID)
if row.Cycle {
return SourceContextBuild{}, ErrSourceContextInvalid
}
if _, exists := seenAncestors[id]; exists {
return SourceContextBuild{}, ErrSourceContextInvalid
}
seenAncestors[id] = struct{}{}
}
historyRows, err := q.ListCommentThreadHistory(ctx, db.ListCommentThreadHistoryParams{
RootID: ancestorRows[0].ID,
WorkspaceID: workspaceID,
IssueID: anchor.IssueID,
AnchorCreatedAt: anchor.CreatedAt,
AnchorID: anchorCommentID,
RowLimit: SourceContextMaxComments + 1,
})
if err != nil {
return SourceContextBuild{}, fmt.Errorf("load source thread history: %w", err)
}
if len(historyRows) == 0 || historyRows[0].ID != ancestorRows[0].ID || historyRows[len(historyRows)-1].ID != anchorCommentID {
return SourceContextBuild{}, ErrSourceContextInvalid
}
if len(historyRows) > SourceContextMaxComments {
return SourceContextBuild{Limits: SourceContextLimitUsage{CommentCount: len(historyRows)}}, ErrSourceContextTooLarge
}
seenHistory := make(map[string]struct{}, len(historyRows))
for _, row := range historyRows {
id := util.UUIDToString(row.ID)
if _, exists := seenHistory[id]; exists {
return SourceContextBuild{}, ErrSourceContextInvalid
}
seenHistory[id] = struct{}{}
}
commentThread, commentAttachments, err := buildSourceContextCommentSnapshots(ctx, q, workspaceID, anchor.IssueID, historyRows)
if err != nil {
return SourceContextBuild{}, err
}
allRows := make([]db.Attachment, 0, len(issueAttachments)+len(commentAttachments))
for _, attachment := range issueAttachments {
allRows = append(allRows, attachment)
}
for _, attachment := range commentAttachments {
allRows = append(allRows, attachment)
}
snapshot := SourceContextSnapshot{
SourceIssue: issueSnapshot,
CommentThread: commentThread,
AnchorCommentID: util.UUIDToString(anchorCommentID),
}
payload, err := json.Marshal(snapshot)
if err != nil {
return SourceContextBuild{}, fmt.Errorf("marshal source context: %w", err)
}
limits := SourceContextLimitUsage{CommentCount: len(commentThread), TextBytes: len(payload), AttachmentCount: len(allRows)}
for _, row := range allRows {
limits.AttachmentBytes += row.SizeBytes
}
if limits.TextBytes > SourceContextMaxTextBytes || limits.AttachmentCount > SourceContextMaxAttachments || limits.AttachmentBytes > SourceContextMaxAttachmentBytes {
return SourceContextBuild{Snapshot: snapshot, Limits: limits}, ErrSourceContextTooLarge
}
// Revisions and update timestamps are useful capture metadata, but they are
// not part of the canonical content comparison. A source that was edited
// and restored byte-for-byte must match the preview token again.
digest, err := sourceContextDigest(snapshot)
if err != nil {
return SourceContextBuild{}, err
}
return SourceContextBuild{
Snapshot: snapshot,
Digest: digest,
Token: "sha256:" + digest + ":" + util.UUIDToString(anchor.IssueID),
Limits: limits,
Rows: allRows,
}, nil
}
func ParseSourceContextToken(token string) (digest string, sourceIssueID pgtype.UUID, ok bool) {
parts := strings.Split(token, ":")
if len(parts) != 3 || parts[0] != "sha256" || len(parts[1]) != sha256.Size*2 {
return "", pgtype.UUID{}, false
}
if _, err := hex.DecodeString(parts[1]); err != nil {
return "", pgtype.UUID{}, false
}
id, err := util.ParseUUID(parts[2])
if err != nil {
return "", pgtype.UUID{}, false
}
return parts[1], id, true
}
func PrepareSourceContextCapture(build SourceContextBuild, contextID, workspaceID, capturedBy pgtype.UUID, capturedAt time.Time, clones []SourceContextClone) (SourceContextCapture, error) {
if len(clones) != len(build.Rows) {
return SourceContextCapture{}, fmt.Errorf("source context clone count mismatch")
}
// The preview build can remain cached by the caller while storage cloning
// runs. Deep-copy before replacing original attachment IDs with clone IDs so
// the preview/token input remains immutable.
snapshotJSON, err := json.Marshal(build.Snapshot)
if err != nil {
return SourceContextCapture{}, fmt.Errorf("copy source context snapshot: %w", err)
}
var snapshot SourceContextSnapshot
if err := json.Unmarshal(snapshotJSON, &snapshot); err != nil {
return SourceContextCapture{}, fmt.Errorf("copy source context snapshot: %w", err)
}
cloneBySource := make(map[string]string, len(clones))
replacements := make([]string, 0, len(clones)*2)
for i, row := range build.Rows {
sourceID := util.UUIDToString(row.ID)
cloneID := util.UUIDToString(clones[i].ID)
sourceDownloadPath := util.AttachmentDownloadPath(sourceID)
cloneDownloadPath := util.AttachmentDownloadPath(cloneID)
cloneBySource[sourceID] = cloneID
replacements = append(
replacements,
sourceDownloadPath,
cloneDownloadPath,
)
// Public-storage deployments persist the attachment object's URL
// directly. It is just as source-owned as the stable API route and must
// resolve through the clone after the original object is removed.
if row.Url != "" && row.Url != sourceDownloadPath {
replacements = append(replacements, row.Url, cloneDownloadPath)
}
}
replacer := strings.NewReplacer(replacements...)
rewriteContent := func(content string) string {
return replacer.Replace(content)
}
rewrite := func(items []SourceContextAttachment) {
for i := range items {
sourceID := items[i].ID
items[i].SourceAttachmentID = sourceID
items[i].ID = cloneBySource[sourceID]
}
}
rewrite(snapshot.SourceIssue.Attachments)
if snapshot.SourceIssue.Description != nil {
description := rewriteContent(*snapshot.SourceIssue.Description)
snapshot.SourceIssue.Description = &description
}
for i := range snapshot.CommentThread {
rewrite(snapshot.CommentThread[i].Attachments)
snapshot.CommentThread[i].Content = rewriteContent(snapshot.CommentThread[i].Content)
}
snapshot.Version = SourceContextVersion
snapshot.CapturedByUserID = util.UUIDToString(capturedBy)
snapshot.CapturedAt = capturedAt.UTC().Format(time.RFC3339Nano)
return SourceContextCapture{
ID: contextID, WorkspaceID: workspaceID,
SourceIssueID: issueUUID(build.Snapshot.SourceIssue.ID), AnchorCommentID: issueUUID(build.Snapshot.AnchorCommentID),
CapturedByUserID: capturedBy, CapturedAt: capturedAt.UTC(), Snapshot: snapshot, Digest: build.Digest, Clones: clones,
}, nil
}
func issueUUID(value string) pgtype.UUID {
id, err := util.ParseUUID(value)
if err != nil {
panic(err)
}
return id
}
func PersistSourceContext(ctx context.Context, q *db.Queries, capture SourceContextCapture, issueID, taskID pgtype.UUID) (db.IssueSourceContext, error) {
state := "pending"
var attachedAt pgtype.Timestamptz
if issueID.Valid {
state = "attached"
attachedAt = pgtype.Timestamptz{Time: capture.CapturedAt, Valid: true}
}
snapshotJSON, err := json.Marshal(capture.Snapshot)
if err != nil {
return db.IssueSourceContext{}, err
}
row, err := q.CreateIssueSourceContext(ctx, db.CreateIssueSourceContextParams{
ID: capture.ID, WorkspaceID: capture.WorkspaceID, IssueID: issueID, OriginTaskID: taskID,
SourceIssueID: capture.SourceIssueID, AnchorCommentID: capture.AnchorCommentID,
CapturedByUserID: capture.CapturedByUserID, SnapshotVersion: SourceContextVersion,
Snapshot: snapshotJSON, CaptureDigest: capture.Digest, State: state,
CapturedAt: pgtype.Timestamptz{Time: capture.CapturedAt, Valid: true}, AttachedAt: attachedAt,
})
if err != nil {
return db.IssueSourceContext{}, err
}
for _, clone := range capture.Clones {
if _, err := q.CreateSourceContextAttachment(ctx, db.CreateSourceContextAttachmentParams{
ID: clone.ID, WorkspaceID: capture.WorkspaceID, SourceContextID: capture.ID,
UploaderType: "member", UploaderID: capture.CapturedByUserID,
Filename: clone.Filename, Url: clone.URL, ContentType: clone.ContentType, SizeBytes: clone.SizeBytes,
}); err != nil {
return db.IssueSourceContext{}, err
}
}
intents, err := q.DeletePendingSourceContextObjectIntents(ctx, db.DeletePendingSourceContextObjectIntentsParams{
WorkspaceID: capture.WorkspaceID, SourceContextID: capture.ID,
})
if err != nil {
return db.IssueSourceContext{}, fmt.Errorf("clear source context object intents: %w", err)
}
if len(intents) != len(capture.Clones) {
return db.IssueSourceContext{}, fmt.Errorf("source context object intent ownership changed: cleared %d, want %d", len(intents), len(capture.Clones))
}
return row, nil
}
// CleanupSourceContextObjectIntents reconciles clone uploads that never
// reached the context transaction (request cancellation, crash, or ambiguous
// storage/DB failure). The one-hour settle window is fenced by a DB state
// claim: once cleanup owns an intent, PersistSourceContext cannot delete the
// full pending intent set and therefore rolls back instead of attaching an
// object being reclaimed.
func (s *TaskService) CleanupSourceContextObjectIntents(ctx context.Context, limit int) (int, error) {
if s == nil || s.SourceContextStorage == nil || limit <= 0 {
return 0, nil
}
cleaned := 0
// limit bounds ATTEMPTS, not successes. Counting successes alone made one
// round's work proportional to the number of due intents rather than to the
// batch size, which is exactly what a storage outage produces: every delete
// fails, nothing increments, and the round keeps claiming the next row.
for attempt := 0; attempt < limit; attempt++ {
if ctx.Err() != nil {
return cleaned, nil
}
leaseToken := dbid.NewV7()
intent, err := s.Queries.ClaimSourceContextObjectIntentForCleanup(ctx, leaseToken)
if errors.Is(err, pgx.ErrNoRows) {
break
}
if err != nil {
// A round that ran out of budget mid-claim is a normal stop, not a
// failure worth alerting on; the next round resumes where this left off.
if ctx.Err() != nil {
return cleaned, nil
}
return cleaned, fmt.Errorf("claim source context object intent: %w", err)
}
referenced, err := s.Queries.SourceContextObjectIntentIsReferenced(ctx, db.SourceContextObjectIntentIsReferencedParams{
WorkspaceID: intent.WorkspaceID, SourceContextID: intent.SourceContextID,
AttachmentID: intent.AttachmentID, ObjectUrl: intent.ObjectUrl,
})
if err != nil {
s.releaseSourceContextObjectIntent(ctx, intent, leaseToken, err)
return cleaned, fmt.Errorf("check source context object reference: %w", err)
}
if !referenced {
deleteCtx, cancel := context.WithTimeout(ctx, sourceContextObjectDeleteTimeout)
deleteErr := s.SourceContextStorage.DeleteObject(deleteCtx, intent.StorageKey)
cancel()
if deleteErr != nil {
s.releaseSourceContextObjectIntent(ctx, intent, leaseToken, deleteErr)
continue
}
}
changed, err := s.Queries.DeleteClaimedSourceContextObjectIntent(ctx, db.DeleteClaimedSourceContextObjectIntentParams{
StorageKey: intent.StorageKey, WorkspaceID: intent.WorkspaceID, LeaseToken: leaseToken,
})
if err != nil || changed != 1 {
return cleaned, fmt.Errorf("delete source context object intent: changed=%d: %w", changed, err)
}
cleaned++
}
return cleaned, nil
}
// releaseSourceContextObjectIntent stamps the retry backoff for an intent this
// round could not settle. The write is detached from the round budget on
// purpose: when the budget is what stopped the delete, a cancelled release
// would leave the row claimable with no backoff, so the next round would pick
// the same unhealthy object again instead of making progress on the rest.
func (s *TaskService) releaseSourceContextObjectIntent(ctx context.Context, intent db.IssueSourceContextObjectIntent, leaseToken pgtype.UUID, cause error) {
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), sourceContextIntentReleaseTimeout)
defer cancel()
if _, err := s.Queries.ReleaseSourceContextObjectIntent(releaseCtx, db.ReleaseSourceContextObjectIntentParams{
LastError: pgtype.Text{String: cause.Error(), Valid: true}, StorageKey: intent.StorageKey,
WorkspaceID: intent.WorkspaceID, LeaseToken: leaseToken,
}); err != nil {
slog.Warn("source context cleanup: failed to record object intent retry backoff",
"storage_key", intent.StorageKey, "error", err)
}
}
// CleanupAbandonedSourceContexts abandons terminal quick-create captures only
// after their 30-day retry window. Retry creation transfers origin_task_id to
// the successor, so the terminal row selected here is the latest attempt.
// Object deletion deliberately runs after the state transaction commits and
// without a row lock. Failed deletes leave the abandoned context and its
// attachment rows for an idempotent retry. Once every object is gone, the
// attachment inventory and full captured snapshot are removed together.
func (s *TaskService) CleanupAbandonedSourceContexts(ctx context.Context, limit int32) (int, error) {
if s == nil || s.TxStarter == nil || s.SourceContextStorage == nil || limit <= 0 {
return 0, nil
}
tx, err := s.TxStarter.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin source context cleanup: %w", err)
}
defer tx.Rollback(ctx)
qtx := s.Queries.WithTx(tx)
contexts, err := qtx.ListExpiredPendingSourceContextsForCleanup(ctx, limit)
if err != nil {
return 0, fmt.Errorf("list expired source contexts: %w", err)
}
for _, sourceContext := range contexts {
changed, err := qtx.AbandonIssueSourceContext(ctx, db.AbandonIssueSourceContextParams{
ID: sourceContext.ID, WorkspaceID: sourceContext.WorkspaceID,
})
if err != nil || changed != 1 {
return 0, fmt.Errorf("abandon expired source context: changed=%d: %w", changed, err)
}
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit source context cleanup: %w", err)
}
abandoned, err := s.Queries.ListAbandonedSourceContextsForCleanup(ctx, limit)
if err != nil {
return 0, fmt.Errorf("list abandoned source contexts: %w", err)
}
removed := 0
for _, sourceContext := range abandoned {
// Stop cleanly when the round budget is gone. Every deletion below is
// idempotent, so the next round resumes from whatever is still abandoned.
if ctx.Err() != nil {
return removed, nil
}
attachments, err := s.Queries.ListAttachmentsBySourceContext(ctx, db.ListAttachmentsBySourceContextParams{
WorkspaceID: sourceContext.WorkspaceID, SourceContextID: sourceContext.ID,
})
if err != nil {
return removed, fmt.Errorf("list abandoned source context attachments: %w", err)
}
deleteFailed := false
for _, attachment := range attachments {
if ctx.Err() != nil {
return removed, nil
}
key := s.SourceContextStorage.KeyFromURL(attachment.Url)
deleteCtx, cancel := context.WithTimeout(ctx, sourceContextObjectDeleteTimeout)
err := s.SourceContextStorage.DeleteObject(deleteCtx, key)
cancel()
if err != nil {
deleteFailed = true
slog.Warn("source context cleanup: object delete failed; retained row for retry",
"source_context_id", util.UUIDToString(sourceContext.ID), "attachment_id", util.UUIDToString(attachment.ID), "error", err)
}
}
if deleteFailed {
continue
}
if _, err := s.Queries.DeleteAttachmentsBySourceContext(ctx, db.DeleteAttachmentsBySourceContextParams{
WorkspaceID: sourceContext.WorkspaceID, SourceContextID: sourceContext.ID,
}); err != nil {
return removed, fmt.Errorf("delete abandoned source context attachment rows: %w", err)
}
changed, err := s.Queries.DeleteAbandonedIssueSourceContext(ctx, db.DeleteAbandonedIssueSourceContextParams{
WorkspaceID: sourceContext.WorkspaceID, ID: sourceContext.ID,
})
if err != nil {
return removed, fmt.Errorf("delete abandoned source context: %w", err)
}
// Multiple server instances can sweep the same abandoned row. Storage
// deletes are idempotent; a zero-row delete means another instance won
// the final DB cleanup and is therefore already the desired outcome.
if changed == 0 {
continue
}
removed++
}
return removed, nil
}