From 55c542f83d7d677b7b7e4efe9d9e2a373135e818 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 09:25:44 -0500 Subject: [PATCH 01/14] Make issue close safe to retry --- api/openapi.yaml | 8 ++ cmd/kata/close.go | 40 +++++-- cmd/kata/close_reopen_test.go | 31 +++++ docs/reference/cli.md | 14 ++- internal/api/types.go | 49 +++++--- internal/daemon/handlers_actions.go | 107 ++++++++++++++++- .../daemon/handlers_actions_retry_test.go | 109 ++++++++++++++++++ internal/db/dbtest/conformance.go | 4 +- internal/db/dbtest/conformance_core.go | 44 +++++++ internal/db/errors.go | 4 +- internal/db/params.go | 15 +++ internal/db/pgstore/idempotency.go | 24 +++- internal/db/pgstore/issue_lifecycle.go | 52 +++++---- .../db/pgstore/issue_lifecycle_retry_test.go | 2 +- internal/db/pgstore/stubgen/main.go | 2 + internal/db/pgstore/stubgen/main_test.go | 4 +- internal/db/sqlitestore/idempotency_lock.go | 2 +- internal/db/sqlitestore/queries.go | 70 ++++++----- .../db/sqlitestore/queries_idempotency.go | 19 ++- internal/db/storage.go | 4 +- internal/db/types.go | 6 +- pkg/client/generated/client_options.go | 11 +- pkg/client/generated/headers.go | 5 + pkg/client/openapi.yaml | 8 ++ 24 files changed, 534 insertions(+), 100 deletions(-) create mode 100644 internal/daemon/handlers_actions_retry_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index f896fbc46..a9543fbd1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -6271,6 +6271,14 @@ paths: required: true schema: type: string + - in: header + name: Idempotency-Key + schema: + type: string + - in: header + name: If-Match + schema: + type: string requestBody: content: application/json: diff --git a/cmd/kata/close.go b/cmd/kata/close.go index 433c3e2bd..6fcf56575 100644 --- a/cmd/kata/close.go +++ b/cmd/kata/close.go @@ -14,10 +14,12 @@ import ( func newCloseCmd() *cobra.Command { var ( - reason string - message string - evidence []string - dryRun bool + reason string + message string + evidence []string + dryRun bool + idempotencyKey string + ifMatch string sugarDone bool sugarWontfix bool @@ -50,6 +52,9 @@ Instead, label and comment: kata comment --body "what was attempted, what remains"`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if err := validateMetaIfMatchFlag(cmd, ifMatch); err != nil { + return err + } // Resolve sugar -> reason (with conflict checks). Multiple sugar // flags are mutually exclusive: a sequential switch would silently // keep the first match and drop the rest (`--done --wontfix` would @@ -127,7 +132,18 @@ Instead, label and comment: if dryRun && currentOutputMode() == outputHuman && !flags.Quiet { _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "close: dry-run (no mutations will occur)") } - return runAction(cmd, args[0], "close", extra) + headers := map[string]string{} + if idempotencyKey != "" { + headers["Idempotency-Key"] = idempotencyKey + } + if strings.TrimSpace(ifMatch) != "" { + etag, err := normalizeMetaIfMatch(ifMatch) + if err != nil { + return err + } + headers["If-Match"] = etag + } + return runActionWithHeaders(cmd, args[0], "close", extra, headers) }, } cmd.Flags().StringVar(&reason, "reason", "", @@ -144,6 +160,10 @@ Instead, label and comment: "duplicate-of:, superseded-by:") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "validate without mutating; reports the would-be close event") + cmd.Flags().StringVar(&idempotencyKey, "idempotency-key", "", + "send Idempotency-Key header for safe retry") + cmd.Flags().StringVar(&ifMatch, "if-match", "", + "expected issue revision (N or rev-N)") cmd.Flags().BoolVar(&sugarDone, "done", false, "sugar for --reason done") cmd.Flags().BoolVar(&sugarWontfix, "wontfix", false, "sugar for --reason wontfix") @@ -213,6 +233,12 @@ func parseEvidenceFlags(raw []string) ([]api.Evidence, error) { // the daemon action endpoint. If --comment was passed on the command, the // comment is appended in a separate POST after the action succeeds. func runAction(cmd *cobra.Command, raw, action string, extra map[string]any) error { + return runActionWithHeaders(cmd, raw, action, extra, nil) +} + +func runActionWithHeaders( + cmd *cobra.Command, raw, action string, extra map[string]any, headers map[string]string, +) error { comment, err := commentFromFlag(cmd) if err != nil { return err @@ -228,9 +254,9 @@ func runAction(cmd *cobra.Command, raw, action string, extra map[string]any) err if err != nil { return err } - status, bs, err := httpDoJSON(ctx, client, http.MethodPost, + status, bs, err := httpDoJSONHeaders(ctx, client, http.MethodPost, fmt.Sprintf("%s/api/v1/projects/%d/issues/%s/actions/%s", baseURL, pid, url.PathEscape(issue.RefForAPI), action), - body) + body, headers) if err != nil { return err } diff --git a/cmd/kata/close_reopen_test.go b/cmd/kata/close_reopen_test.go index 5fac50648..fe783d368 100644 --- a/cmd/kata/close_reopen_test.go +++ b/cmd/kata/close_reopen_test.go @@ -57,6 +57,37 @@ func TestClose_AgentOutputExternalEvidence(t *testing.T) { assert.Contains(t, audit, `"evidence_types":["external"]`) } +func TestCloseCmd_RetryFlagsReplayOriginalReceipt(t *testing.T) { + env, dir, _, ref := setupWorkspaceWithIssue(t, "test issue") + args := []string{ + "--json", "close", ref, + "--done", + "--message", "Implemented the requested behavior and ran the focused tests.", + "--test", "go test ./cmd/kata", + "--idempotency-key", "close-request-1", + "--if-match", "1", + } + first := runCLI(t, env, dir, args...) + assert.Contains(t, first, `"changed":true`) + + second := runCLI(t, env, dir, args...) + assert.Contains(t, second, `"changed":false`) + assert.Contains(t, second, `"reused":true`) + assert.Contains(t, second, `"original_event":`) +} + +func TestCloseCmd_RejectsBlankIfMatch(t *testing.T) { + env, dir, _, ref := setupWorkspaceWithIssue(t, "test issue") + _, stderr, err := runCLIWithErr(t, env, dir, + "close", ref, + "--done", + "--message", "Implemented the requested behavior and ran the focused tests.", + "--test", "go test ./cmd/kata", + "--if-match", "") + require.Error(t, err) + assert.Contains(t, stderr, "--if-match must not be blank") +} + func TestClose_AgentDryRunSuppressesHumanBanner(t *testing.T) { env, dir, _, ref := setupWorkspaceWithIssue(t, "test issue") diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 026af0e37..ba4e95b0d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -282,9 +282,21 @@ kata close --done --message \ [--pr ] \ [--test ] \ [--reviewed ] \ - [--evidence ] + [--evidence ] \ + [--idempotency-key ] \ + [--if-match ] ``` +`--idempotency-key` makes a close safe to retry after a lost response. For +seven days, an exact retry returns the original `issue.closed` event through +`original_event` and does not close the issue again. Reusing the key with a +different issue, actor, close payload, or revision returns a conflict. + +`--if-match` accepts `7` or `rev-7`. The daemon checks that revision inside the +close transaction and returns a revision conflict when it has changed. An +exact retry with a matching idempotency key returns the committed receipt even +after the original close advanced the issue state. + Evidence is validated against the close reason: | Reason | Evidence rule | diff --git a/internal/api/types.go b/internal/api/types.go index 6667baf51..7ce551126 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -822,7 +822,8 @@ type CommentResponse struct { } } -// ActionRequest is POST /api/v1/projects/{id}/issues/{ref}/actions/close|reopen. +// ActionRequest is POST /api/v1/projects/{id}/issues/{ref}/actions/reopen. +// CloseActionRequest adds the close-only retry and revision headers. // Reason is enforced to the schema's CHECK list so unsupported values surface // as 400 validation rather than a SQLite constraint failure (500 internal). // Message, Evidence, and DryRun are close-only inputs (anti-agent-justification); @@ -830,23 +831,35 @@ type CommentResponse struct { type ActionRequest struct { ProjectID int64 `path:"project_id" required:"true"` Ref string `path:"ref" required:"true"` - Body struct { - Actor string `json:"actor,omitempty"` - Reason string `json:"reason,omitempty" enum:"done,wontfix,duplicate,superseded,audit-no-change,"` - Message string `json:"message,omitempty"` - // Source signals the caller's UI surface. "tui" relaxes the - // substance / evidence validation so an interactive human close - // is one keystroke, but only over an owner-local Unix socket or - // direct, unforwarded loopback TCP connection. Forwarded TCP, - // non-loopback TCP, and identity-backed, trusted-proxy, or browser - // principals get full validation even when they send source="tui". - // Structural guards - // (parent-close, sibling throttle) always apply. Empty string means - // "agent / CLI" and gets full validation. - Source string `json:"source,omitempty" enum:"tui,"` - Evidence []Evidence `json:"evidence,omitempty"` - DryRun bool `json:"dry_run,omitempty"` - } + Body ActionRequestBody +} + +// CloseActionRequest adds retry and optimistic-concurrency headers to the +// shared issue action body. +type CloseActionRequest struct { + ProjectID int64 `path:"project_id" required:"true"` + Ref string `path:"ref" required:"true"` + IdempotencyKey string `header:"Idempotency-Key"` + IfMatch string `header:"If-Match"` + Body ActionRequestBody +} + +// ActionRequestBody is the shared JSON body for close and reopen actions. +type ActionRequestBody struct { + Actor string `json:"actor,omitempty"` + Reason string `json:"reason,omitempty" enum:"done,wontfix,duplicate,superseded,audit-no-change,"` + Message string `json:"message,omitempty"` + // Source signals the caller's UI surface. "tui" relaxes the + // substance / evidence validation so an interactive human close + // is one keystroke, but only over an owner-local Unix socket or + // direct, unforwarded loopback TCP connection. Forwarded TCP, + // non-loopback TCP, and identity-backed, trusted-proxy, or browser + // principals get full validation even when they send source="tui". + // Structural guards (parent-close, sibling throttle) always apply. + // Empty string means "agent / CLI" and gets full validation. + Source string `json:"source,omitempty" enum:"tui,"` + Evidence []Evidence `json:"evidence,omitempty"` + DryRun bool `json:"dry_run,omitempty"` } // CreateLinkRequest is POST /api/v1/projects/{id}/issues/{ref}/links. diff --git a/internal/daemon/handlers_actions.go b/internal/daemon/handlers_actions.go index 3fa828537..335524b0c 100644 --- a/internal/daemon/handlers_actions.go +++ b/internal/daemon/handlers_actions.go @@ -2,6 +2,9 @@ package daemon import ( "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" "time" @@ -21,7 +24,7 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { OperationID: "closeIssue", Method: "POST", Path: "/api/v1/projects/{project_id}/issues/{ref}/actions/close", - }, func(ctx context.Context, in *api.ActionRequest) (*api.MutationResponse, error) { + }, func(ctx context.Context, in *api.CloseActionRequest) (*api.MutationResponse, error) { actor, err := attributedActor(ctx, in.Body.Actor) if err != nil { return nil, err @@ -42,6 +45,10 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { if in.Body.Source == "tui" && in.Body.Reason == "" { in.Body.Reason = "done" } + ifMatchRev, err := parseOptionalIfMatchRevision(in.IfMatch) + if err != nil { + return nil, err + } // The TUI bypass is scoped to reason="done" — the only shape // the interactive "press x to close" path ever produces. A // caller sending source="tui" with reason="duplicate" or @@ -55,6 +62,26 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { if err != nil { return nil, err } + idempotencyFingerprint := "" + if in.IdempotencyKey != "" { + release, err := cfg.DB.AcquireIdempotencyLock(ctx, in.ProjectID, in.IdempotencyKey) + if err != nil { + return nil, internalAPIError(err) + } + defer func() { _ = release() }() + + idempotencyFingerprint = closeIdempotencyFingerprint( + issue.UID, actor, in.Body.Reason, in.Body.Message, in.Body.Source, + in.Body.Evidence, in.Body.DryRun, ifMatchRev) + reuse, err := tryCloseIdempotencyMatch( + ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint) + if err != nil { + return nil, err + } + if reuse != nil { + return reuse, nil + } + } // Already-closed short-circuit. CloseIssue itself returns // changed=false for this case; short-circuiting before the // guards (and substance / evidence validation) keeps idempotent @@ -137,14 +164,21 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { var err error evt = nil events = nil - updated, events, changed, err = cfg.DB.CloseIssueWithEvents(ctx, issue.ID, - in.Body.Reason, actor, in.Body.Message, dbEvidence) + updated, events, changed, err = cfg.DB.CloseIssueGuarded(ctx, db.CloseIssueParams{ + IssueID: issue.ID, Reason: in.Body.Reason, Actor: actor, + Message: in.Body.Message, Evidence: dbEvidence, IfMatchRev: ifMatchRev, + IdempotencyKey: in.IdempotencyKey, IdempotencyFingerprint: idempotencyFingerprint, + }) if len(events) > 0 { evt = &events[0] } return err }) if err != nil { + if revisionConflict, ok := errors.AsType[*db.RevisionConflictError](err); ok { + return nil, api.NewError(412, "revision_conflict", + fmt.Sprintf("issue revision is %d", revisionConflict.CurrentRevision), "", nil) + } // In-transaction guard re-fires when a concurrent link/create // added an open child between the read-side guard and the // close write. Map it to the same 409 code so clients see @@ -163,6 +197,19 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { } return nil, internalAPIError(err) } + // A database retry can observe that its first attempt committed even + // when the commit response was lost. Recover that attempt's receipt + // before returning a plain no-op response. + if !changed && in.IdempotencyKey != "" { + reuse, err := tryCloseIdempotencyMatch( + ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint) + if err != nil { + return nil, err + } + if reuse != nil { + return reuse, nil + } + } if changed { cfg.Publish().Events(in.ProjectID, events) } @@ -219,6 +266,60 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { }) } +func tryCloseIdempotencyMatch( + ctx context.Context, + cfg ServerConfig, + projectID int64, + key, fingerprint string, +) (*api.MutationResponse, error) { + match, err := cfg.DB.LookupIssueMutationIdempotency( + ctx, projectID, "issue.closed", key, time.Now().Add(-idempotencyWindow)) + if err != nil { + return nil, internalAPIError(err) + } + if match == nil { + return nil, nil + } + if match.Fingerprint != fingerprint { + return nil, api.NewError(409, "idempotency_mismatch", + "idempotency key matched a prior close with a different fingerprint", + "use a fresh key or send the exact original close request", nil) + } + current, err := cfg.DB.IssueByID(ctx, match.IssueID) + if err != nil { + return nil, internalAPIError(err) + } + original := match.Event + out := &api.MutationResponse{} + out.Body.Issue = current + out.Body.OriginalEvent = &original + out.Body.Reused = true + return out, nil +} + +func closeIdempotencyFingerprint( + issueUID, actor, reason, message, source string, + evidence []api.Evidence, + dryRun bool, + ifMatchRev *int64, +) string { + encoded, _ := json.Marshal(struct { + IssueUID string `json:"issue_uid"` + Actor string `json:"actor"` + Reason string `json:"reason"` + Message string `json:"message"` + Source string `json:"source"` + Evidence []api.Evidence `json:"evidence"` + DryRun bool `json:"dry_run"` + IfMatchRev *int64 `json:"if_match_revision"` + }{ + IssueUID: issueUID, Actor: actor, Reason: reason, Message: message, + Source: source, Evidence: evidence, DryRun: dryRun, IfMatchRev: ifMatchRev, + }) + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]) +} + // validateEvidenceTargets resolves duplicate-of and superseded-by issue // refs in the same project and rejects targets that are missing or that // point at the issue being closed. ValidateCloseInput already checks that diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go new file mode 100644 index 000000000..8076c2b13 --- /dev/null +++ b/internal/daemon/handlers_actions_retry_test.go @@ -0,0 +1,109 @@ +package daemon_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/kata/internal/api" + "go.kenn.io/kata/internal/db" +) + +func TestClose_IdempotencyReplaysCommittedReceipt(t *testing.T) { + h, ts, projectID, issueID := bootstrapProjectWithIssue(t) + issue, err := h.DB().IssueByID(context.Background(), issueID) + require.NoError(t, err) + path := issueURLRef(projectID, issue.ShortID, "actions/close") + headers := map[string]string{ + "Idempotency-Key": "close-request-1", + "If-Match": `"rev-1"`, + } + body := map[string]any{ + "actor": "agent-one", + "reason": "done", + "message": "Implemented the requested behavior and ran the focused tests.", + "evidence": []map[string]any{{ + "type": "test", "command": "go test ./internal/daemon", + }}, + } + + first := postWithHeader(t, ts, path, headers, body) + requireOK(t, first) + var firstOut api.MutationResponse + require.NoError(t, json.Unmarshal(first.body, &firstOut.Body)) + require.True(t, firstOut.Body.Changed) + require.NotNil(t, firstOut.Body.Event) + assert.False(t, firstOut.Body.Reused) + + second := postWithHeader(t, ts, path, headers, body) + requireOK(t, second) + var secondOut api.MutationResponse + require.NoError(t, json.Unmarshal(second.body, &secondOut.Body)) + assert.False(t, secondOut.Body.Changed) + assert.True(t, secondOut.Body.Reused) + assert.Nil(t, secondOut.Body.Event) + require.NotNil(t, secondOut.Body.OriginalEvent) + assert.Equal(t, firstOut.Body.Event.UID, secondOut.Body.OriginalEvent.UID) + + events, err := h.DB().EventsAfter(context.Background(), db.EventsAfterParams{ + ProjectID: projectID, + Limit: 100, + }) + require.NoError(t, err) + closed := 0 + for _, event := range events { + if event.Type == "issue.closed" && event.IssueID != nil && *event.IssueID == issueID { + closed++ + } + } + assert.Equal(t, 1, closed) +} + +func TestClose_IdempotencyRejectsDifferentRequest(t *testing.T) { + _, ts, projectID, issueID := bootstrapProjectWithIssue(t) + path := issueURL(projectID, issueID, "actions/close") + headers := map[string]string{"Idempotency-Key": "close-request-1"} + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + } + requireOK(t, postWithHeader(t, ts, path, headers, body)) + + body["message"] = "A different explanation must not reuse the original close receipt." + retry := postWithHeader(t, ts, path, headers, body) + assertAPIError(t, retry.status, retry.body, http.StatusConflict, "idempotency_mismatch") +} + +func TestClose_IfMatchRejectsStaleRevision(t *testing.T) { + h, ts, projectID, issueID := bootstrapProjectWithIssue(t) + _, err := h.DB().PatchIssueMetadata(context.Background(), db.PatchIssueMetadataIn{ + IssueID: issueID, + Actor: "coordinator", + Patch: map[string]json.RawMessage{ + "work.state": json.RawMessage(`"ready"`), + }, + }) + require.NoError(t, err) + + body := map[string]any{ + "actor": "agent-one", + "reason": "done", + "message": "Implemented the requested behavior and ran the focused tests.", + "evidence": []map[string]any{{ + "type": "test", "command": "go test ./internal/daemon", + }}, + } + response := postWithHeader(t, ts, issueURL(projectID, issueID, "actions/close"), + map[string]string{"If-Match": `"rev-1"`}, body) + assertAPIError(t, response.status, response.body, + http.StatusPreconditionFailed, "revision_conflict") + + issue, err := h.DB().IssueByID(context.Background(), issueID) + require.NoError(t, err) + assert.Equal(t, "open", issue.Status) +} diff --git a/internal/db/dbtest/conformance.go b/internal/db/dbtest/conformance.go index 96ea16296..d4463be62 100644 --- a/internal/db/dbtest/conformance.go +++ b/internal/db/dbtest/conformance.go @@ -71,7 +71,7 @@ var storageScenarios = []scenario{ }, { name: "idempotency", - methods: []string{"AcquireIdempotencyLock", "CreateComment", "CreateIssue", "CreateProject", "LookupCommentIdempotency", "LookupIdempotency"}, + methods: []string{"AcquireIdempotencyLock", "CreateComment", "CreateIssue", "CreateProject", "LookupCommentIdempotency", "LookupIdempotency", "LookupIssueMutationIdempotency"}, run: checkIdempotency, }, { @@ -99,7 +99,7 @@ var storageScenarios = []scenario{ { name: "issue lifecycle", methods: []string{ - "BatchProjectStats", "ClaimOwner", "CloseIssue", "CloseIssueWithEvents", "CreateIssue", "CreateProject", "EditIssue", + "BatchProjectStats", "ClaimOwner", "CloseIssue", "CloseIssueGuarded", "CloseIssueWithEvents", "CreateIssue", "CreateProject", "EditIssue", "IssueByShortID", "IssueUIDPrefixMatch", "ListAllIssues", "ReopenIssue", "RestoreIssue", "SoftDeleteIssue", "UpdateOwner", "UpdatePriority", }, diff --git a/internal/db/dbtest/conformance_core.go b/internal/db/dbtest/conformance_core.go index ebc89e140..52f9183de 100644 --- a/internal/db/dbtest/conformance_core.go +++ b/internal/db/dbtest/conformance_core.go @@ -342,6 +342,50 @@ func checkIdempotency(t *testing.T, store db.Storage) error { } assert.Nil(t, missingComment) + closed, closeEvents, changed, err := store.CloseIssueGuarded(ctx, db.CloseIssueParams{ + IssueID: issue.ID, Reason: "wontfix", Actor: "conformance-agent", + Message: "Recorded the reason for stopping this conformance task.", + IdempotencyKey: "close-request-1", IdempotencyFingerprint: "close-fingerprint-1", + IfMatchRev: new(issue.Revision), + }) + if err != nil { + return fmt.Errorf("guarded close: %w", err) + } + assert.True(t, changed) + assert.Equal(t, "closed", closed.Status) + require.NotEmpty(t, closeEvents) + closeMatch, err := store.LookupIssueMutationIdempotency( + ctx, project.ID, "issue.closed", "close-request-1", since) + if err != nil { + return fmt.Errorf("lookup close idempotency: %w", err) + } + require.NotNil(t, closeMatch) + assert.Equal(t, issue.ID, closeMatch.IssueID) + assert.Equal(t, closeEvents[0].UID, closeMatch.Event.UID) + assert.Equal(t, "close-fingerprint-1", closeMatch.Fingerprint) + + staleIssue, _, err := store.CreateIssue(ctx, db.CreateIssueParams{ + ProjectID: project.ID, Title: "stale close guard", Author: "conformance-agent", + }) + if err != nil { + return fmt.Errorf("create stale close issue: %w", err) + } + if _, err := store.PatchIssueMetadata(ctx, db.PatchIssueMetadataIn{ + IssueID: staleIssue.ID, Actor: "conformance-agent", + Patch: map[string]json.RawMessage{"work.state": json.RawMessage(`"ready"`)}, + }); err != nil { + return fmt.Errorf("advance close revision: %w", err) + } + _, _, _, err = store.CloseIssueGuarded(ctx, db.CloseIssueParams{ + IssueID: staleIssue.ID, Reason: "wontfix", Actor: "conformance-agent", + IfMatchRev: new(staleIssue.Revision), + }) + conflict, ok := errors.AsType[*db.RevisionConflictError](err) + if !ok || conflict == nil { + return fmt.Errorf("guarded close returned %v, want revision conflict", err) + } + assert.Equal(t, staleIssue.Revision+1, conflict.CurrentRevision) + releaseFirst, err := store.AcquireIdempotencyLock(ctx, project.ID, "serialized-request") if err != nil { return fmt.Errorf("acquire first idempotency lock: %w", err) diff --git a/internal/db/errors.go b/internal/db/errors.go index 4049bc939..ad7e0c9d2 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -342,8 +342,8 @@ func (e *RecurrencePinnedError) Error() string { return "cannot move: issue is part of a recurrence series" } -// RevisionConflictError is returned by MoveIssueProject when the caller's -// IfMatchRev does not match the issue's current revision. +// RevisionConflictError reports that a caller's IfMatchRev does not match the +// current revision of the guarded record. type RevisionConflictError struct { CurrentRevision int64 } diff --git a/internal/db/params.go b/internal/db/params.go index 4ad1b2732..f60a09a59 100644 --- a/internal/db/params.go +++ b/internal/db/params.go @@ -82,6 +82,21 @@ type CreateIssueParams struct { Metadata map[string]json.RawMessage } +// CloseIssueParams carries optional retry and concurrency guards for one close. +// A nil IfMatchRev preserves the unconditional close behavior. Idempotency +// fields are written to the close event so a caller can recover its original +// receipt after losing the response. +type CloseIssueParams struct { + IssueID int64 + Reason string + Actor string + Message string + Evidence []Evidence + IfMatchRev *int64 + IdempotencyKey string + IdempotencyFingerprint string +} + // ListIssuesParams filters single-project list output. type ListIssuesParams struct { ProjectID int64 diff --git a/internal/db/pgstore/idempotency.go b/internal/db/pgstore/idempotency.go index a5727aafe..61e98fb54 100644 --- a/internal/db/pgstore/idempotency.go +++ b/internal/db/pgstore/idempotency.go @@ -18,12 +18,24 @@ func (s *Store) LookupIdempotency( key string, since time.Time, ) (*db.IdempotencyMatch, error) { - query := eventSelect + ` WHERE e.type = 'issue.created' - AND e.project_id = $1 - AND e.payload::jsonb ->> 'idempotency_key' = $2 - AND e.created_at >= $3 - ORDER BY e.id DESC LIMIT 1` - event, err := scanEvent(s.QueryRowContext(ctx, query, projectID, key, formatStoredTime(since))) + return s.LookupIssueMutationIdempotency(ctx, projectID, "issue.created", key, since) +} + +// LookupIssueMutationIdempotency finds the newest recent issue event of the +// requested type carrying key. +func (s *Store) LookupIssueMutationIdempotency( + ctx context.Context, + projectID int64, + eventType string, + key string, + since time.Time, +) (*db.IdempotencyMatch, error) { + query := eventSelect + ` WHERE e.type = $1 + AND e.project_id = $2 + AND e.payload::jsonb ->> 'idempotency_key' = $3 + AND e.created_at >= $4 + ORDER BY e.id DESC LIMIT 1` + event, err := scanEvent(s.QueryRowContext(ctx, query, eventType, projectID, key, formatStoredTime(since))) if errors.Is(err, db.ErrNotFound) { return nil, nil } diff --git a/internal/db/pgstore/issue_lifecycle.go b/internal/db/pgstore/issue_lifecycle.go index aceb6cf2e..4b46981fc 100644 --- a/internal/db/pgstore/issue_lifecycle.go +++ b/internal/db/pgstore/issue_lifecycle.go @@ -245,22 +245,28 @@ func (s *Store) CloseIssueWithEvents( actor string, message string, evidence []db.Evidence, +) (db.Issue, []db.Event, bool, error) { + return s.CloseIssueGuarded(ctx, db.CloseIssueParams{ + IssueID: issueID, Reason: reason, Actor: actor, Message: message, Evidence: evidence, + }) +} + +// CloseIssueGuarded applies the close and its optional revision precondition in +// the same transaction, then returns every event committed by that mutation. +func (s *Store) CloseIssueGuarded( + ctx context.Context, p db.CloseIssueParams, ) (db.Issue, []db.Event, bool, error) { return s.closeIssueWithEvents( - ctx, issueID, reason, actor, message, evidence, s.withSerializableTx, + ctx, p, s.withSerializableTx, ) } func (s *Store) closeIssueWithEvents( ctx context.Context, - issueID int64, - reason string, - actor string, - message string, - evidence []db.Evidence, + p db.CloseIssueParams, runTx func(context.Context, transactionFunc) error, ) (db.Issue, []db.Event, bool, error) { - if reason == "" { + if p.Reason == "" { return db.Issue{}, nil, false, fmt.Errorf("close reason is required") } var issue db.Issue @@ -268,7 +274,7 @@ func (s *Store) closeIssueWithEvents( var changed bool err := runTx(ctx, func(tx *sql.Tx) error { issue, events, changed = db.Issue{}, nil, false - current, project, err := lockedIssueTx(ctx, tx, issueID, false) + current, project, err := lockedIssueTx(ctx, tx, p.IssueID, false) if err != nil { return err } @@ -276,6 +282,9 @@ func (s *Store) closeIssueWithEvents( issue = current return nil } + if p.IfMatchRev != nil && current.Revision != *p.IfMatchRev { + return &db.RevisionConflictError{CurrentRevision: current.Revision} + } var hasOpenChildren bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS( SELECT 1 FROM links l JOIN issues child ON child.id = l.from_issue_id @@ -289,7 +298,7 @@ func (s *Store) closeIssueWithEvents( } closedAt := mutationTimestamp() if _, err := tx.ExecContext(ctx, `UPDATE issues SET status = 'closed', closed_reason = $1, - closed_at = $2, updated_at = $2 WHERE id = $3`, reason, closedAt, current.ID); err != nil { + closed_at = $2, updated_at = $2 WHERE id = $3`, p.Reason, closedAt, current.ID); err != nil { return mapSQLError(err, nil) } parentUID, parentShortID := new(string), new(string) @@ -304,27 +313,30 @@ func (s *Store) closeIssueWithEvents( return mapSQLError(parentErr, nil) } body, err := json.Marshal(struct { - Reason string `json:"reason"` - ClosedAt string `json:"closed_at"` - Message string `json:"message,omitempty"` - Evidence []db.Evidence `json:"evidence,omitempty"` - ParentUID *string `json:"parent_uid,omitempty"` - ParentShortID *string `json:"parent_short_id,omitempty"` + Reason string `json:"reason"` + ClosedAt string `json:"closed_at"` + Message string `json:"message,omitempty"` + Evidence []db.Evidence `json:"evidence,omitempty"` + ParentUID *string `json:"parent_uid,omitempty"` + ParentShortID *string `json:"parent_short_id,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + IdempotencyFingerprint string `json:"idempotency_fingerprint,omitempty"` }{ - Reason: reason, ClosedAt: closedAt, Message: message, Evidence: evidence, + Reason: p.Reason, ClosedAt: closedAt, Message: p.Message, Evidence: p.Evidence, ParentUID: parentUID, ParentShortID: parentShortID, + IdempotencyKey: p.IdempotencyKey, IdempotencyFingerprint: p.IdempotencyFingerprint, }) if err != nil { return err } created, err := s.insertEventTx(ctx, tx, - issueEventInput(current, project, "issue.closed", actor, string(body))) + issueEventInput(current, project, "issue.closed", p.Actor, string(body))) if err != nil { return err } events, changed = []db.Event{created}, true auditEvents, err := s.annotateClaimWorkMutationTx(ctx, tx, claimWorkMutationInput{ - Project: project, Issue: current, EventType: "issue.closed", Actor: actor, + Project: project, Issue: current, EventType: "issue.closed", Actor: p.Actor, HolderInstanceUID: s.InstanceUID(), OffendingEventUID: created.UID, }) if err != nil { @@ -335,9 +347,9 @@ func (s *Store) closeIssueWithEvents( if len(auditEvents) > 0 { lastEventID = auditEvents[len(auditEvents)-1].ID } - if reason == "done" && current.RecurrenceID != nil && current.OccurrenceKey != nil { + if p.Reason == "done" && current.RecurrenceID != nil && current.OccurrenceKey != nil { if _, err := s.materializeNextTx( - ctx, tx, *current.RecurrenceID, *current.OccurrenceKey, actor, + ctx, tx, *current.RecurrenceID, *current.OccurrenceKey, p.Actor, ); err != nil { return fmt.Errorf("materialize next recurrence: %w", err) } diff --git a/internal/db/pgstore/issue_lifecycle_retry_test.go b/internal/db/pgstore/issue_lifecycle_retry_test.go index ee86bde39..b857c9901 100644 --- a/internal/db/pgstore/issue_lifecycle_retry_test.go +++ b/internal/db/pgstore/issue_lifecycle_retry_test.go @@ -31,7 +31,7 @@ func TestCloseIssueRetryClearsRolledBackAttemptOutput(t *testing.T) { closedAt := mutationTimestamp() closed, events, changed, err := store.closeIssueWithEvents( - ctx, issue.ID, "done", "tester", "", nil, + ctx, db.CloseIssueParams{IssueID: issue.ID, Reason: "done", Actor: "tester"}, rollbackThenRetry(t, store, func() { _, updateErr := store.ExecContext(ctx, `UPDATE issues SET status='closed', closed_reason='done', closed_at=$1, updated_at=$1 WHERE id=$2`, diff --git a/internal/db/pgstore/stubgen/main.go b/internal/db/pgstore/stubgen/main.go index 58643fffa..50dacd262 100644 --- a/internal/db/pgstore/stubgen/main.go +++ b/internal/db/pgstore/stubgen/main.go @@ -105,6 +105,7 @@ var alreadyImplemented = map[string]bool{ "ClearPendingExternalComment": true, // external_roots.go "Close": true, // inherited from embedded *sql.DB "CloseIssue": true, // issue_lifecycle.go + "CloseIssueGuarded": true, // issue_lifecycle.go "CloseIssueWithEvents": true, // issue_lifecycle.go "CommentBodyByID": true, // comments.go "CommentsByIssue": true, // comments.go @@ -207,6 +208,7 @@ var alreadyImplemented = map[string]bool{ "LinksByIssue": true, // links.go "Path": true, // store.go "LookupIdempotency": true, // idempotency.go + "LookupIssueMutationIdempotency": true, // idempotency.go "LookupCommentIdempotency": true, // idempotency.go "MaxEventID": true, // events.go "MaxFederationBaselineEventID": true, // events.go diff --git a/internal/db/pgstore/stubgen/main_test.go b/internal/db/pgstore/stubgen/main_test.go index b3da87aba..640d49bbf 100644 --- a/internal/db/pgstore/stubgen/main_test.go +++ b/internal/db/pgstore/stubgen/main_test.go @@ -38,7 +38,7 @@ type Storage interface { Only(context.Context) error } func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { methods, err := CollectStorageMethodInventory("../../storage.go") require.NoError(t, err) - require.Len(t, methods, 247) + require.Len(t, methods, 249) var implemented []string var stubbed []string @@ -87,6 +87,7 @@ func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { "ClearPendingExternalComment", "Close", "CloseIssue", + "CloseIssueGuarded", "CloseIssueWithEvents", "CommentBodyByID", "CommentsByIssue", @@ -207,6 +208,7 @@ func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { "ListRecurrencesByProject", "LookupCommentIdempotency", "LookupIdempotency", + "LookupIssueMutationIdempotency", "MarkClaimStatusRefreshError", "MarkPendingClaimAttempt", "MaterializeFederatedProject", diff --git a/internal/db/sqlitestore/idempotency_lock.go b/internal/db/sqlitestore/idempotency_lock.go index db2d6a216..e11edcd6a 100644 --- a/internal/db/sqlitestore/idempotency_lock.go +++ b/internal/db/sqlitestore/idempotency_lock.go @@ -22,7 +22,7 @@ func newIdempotencyLockSet() *idempotencyLockSet { return set } -// AcquireIdempotencyLock serializes concurrent create retries within the one +// AcquireIdempotencyLock serializes concurrent mutation retries within the one // SQLite daemon that owns this database. SQLite daemon discovery prevents a // second process from serving the same file; fixed stripes keep memory bounded. func (d *Store) AcquireIdempotencyLock( diff --git a/internal/db/sqlitestore/queries.go b/internal/db/sqlitestore/queries.go index a1d7883fe..78cc71320 100644 --- a/internal/db/sqlitestore/queries.go +++ b/internal/db/sqlitestore/queries.go @@ -1452,19 +1452,26 @@ func (d *Store) CloseIssueWithEvents( issueID int64, reason, actor, message string, evidence []db.Evidence, +) (db.Issue, []db.Event, bool, error) { + return d.CloseIssueGuarded(ctx, db.CloseIssueParams{ + IssueID: issueID, Reason: reason, Actor: actor, Message: message, Evidence: evidence, + }) +} + +// CloseIssueGuarded applies the close and its optional revision precondition in +// the same transaction, then returns every event committed by that mutation. +func (d *Store) CloseIssueGuarded( + ctx context.Context, p db.CloseIssueParams, ) (db.Issue, []db.Event, bool, error) { return retryWrite3(ctx, d, func() (db.Issue, []db.Event, bool, error) { - return d.closeIssueWithEvents(ctx, issueID, reason, actor, message, evidence) + return d.closeIssueGuarded(ctx, p) }) } -func (d *Store) closeIssueWithEvents( - ctx context.Context, - issueID int64, - reason, actor, message string, - evidence []db.Evidence, +func (d *Store) closeIssueGuarded( + ctx context.Context, p db.CloseIssueParams, ) (db.Issue, []db.Event, bool, error) { - if reason == "" { + if p.Reason == "" { return db.Issue{}, nil, false, fmt.Errorf("close: reason is required") } tx, err := d.BeginTx(ctx, nil) @@ -1473,7 +1480,7 @@ func (d *Store) closeIssueWithEvents( } defer func() { _ = tx.Rollback() }() - issue, projectName, err := lookupIssueForEvent(ctx, tx, issueID) + issue, projectName, err := lookupIssueForEvent(ctx, tx, p.IssueID) if err != nil { return db.Issue{}, nil, false, err } @@ -1483,7 +1490,10 @@ func (d *Store) closeIssueWithEvents( } return issue, nil, false, nil } - if hasOpen, err := txHasOpenChildren(ctx, tx, issueID); err != nil { + if p.IfMatchRev != nil && issue.Revision != *p.IfMatchRev { + return db.Issue{}, nil, false, &db.RevisionConflictError{CurrentRevision: issue.Revision} + } + if hasOpen, err := txHasOpenChildren(ctx, tx, p.IssueID); err != nil { return db.Issue{}, nil, false, err } else if hasOpen { return db.Issue{}, nil, false, db.ErrOpenChildren @@ -1495,7 +1505,7 @@ func (d *Store) closeIssueWithEvents( closed_reason = ?, closed_at = ?, updated_at = ? - WHERE id = ?`, reason, closedAt, closedAt, issueID); err != nil { + WHERE id = ?`, p.Reason, closedAt, closedAt, p.IssueID); err != nil { return db.Issue{}, nil, false, fmt.Errorf("close: %w", err) } @@ -1508,7 +1518,7 @@ func (d *Store) closeIssueWithEvents( // at close" (non-nil empty) from "legacy event that predates these // fields" (nil) — the audit projection falls back to a live links // lookup only for the legacy case. - parentUID, parentSID, hasParent, err := txParentIdentity(ctx, tx, issueID) + parentUID, parentSID, hasParent, err := txParentIdentity(ctx, tx, p.IssueID) if err != nil { return db.Issue{}, nil, false, err } @@ -1518,19 +1528,23 @@ func (d *Store) closeIssueWithEvents( *parentSIDForPayload = parentSID } payloadBytes, err := json.Marshal(struct { - Reason string `json:"reason"` - ClosedAt string `json:"closed_at"` - Message string `json:"message,omitempty"` - Evidence []db.Evidence `json:"evidence,omitempty"` - ParentUID *string `json:"parent_uid,omitempty"` - ParentShortID *string `json:"parent_short_id,omitempty"` + Reason string `json:"reason"` + ClosedAt string `json:"closed_at"` + Message string `json:"message,omitempty"` + Evidence []db.Evidence `json:"evidence,omitempty"` + ParentUID *string `json:"parent_uid,omitempty"` + ParentShortID *string `json:"parent_short_id,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + IdempotencyFingerprint string `json:"idempotency_fingerprint,omitempty"` }{ - Reason: reason, - ClosedAt: closedAt, - Message: message, - Evidence: evidence, - ParentUID: parentUIDForPayload, - ParentShortID: parentSIDForPayload, + Reason: p.Reason, + ClosedAt: closedAt, + Message: p.Message, + Evidence: p.Evidence, + ParentUID: parentUIDForPayload, + ParentShortID: parentSIDForPayload, + IdempotencyKey: p.IdempotencyKey, + IdempotencyFingerprint: p.IdempotencyFingerprint, }) if err != nil { return db.Issue{}, nil, false, fmt.Errorf("close payload: %w", err) @@ -1541,7 +1555,7 @@ func (d *Store) closeIssueWithEvents( ProjectName: projectName, IssueID: &issue.ID, Type: "issue.closed", - Actor: actor, + Actor: p.Actor, Payload: string(payloadBytes), }) if err != nil { @@ -1554,7 +1568,7 @@ func (d *Store) closeIssueWithEvents( IssueID: issue.ID, IssueUID: issue.UID, EventType: "issue.closed", - Actor: actor, + Actor: p.Actor, HolderInstanceUID: d.InstanceUID(), }) if err != nil { @@ -1565,9 +1579,9 @@ func (d *Store) closeIssueWithEvents( events = append(events, auditEvents...) lastEventID = auditEvents[len(auditEvents)-1].ID } - if reason == "done" && issue.RecurrenceID != nil && issue.OccurrenceKey != nil { + if p.Reason == "done" && issue.RecurrenceID != nil && issue.OccurrenceKey != nil { if _, err := d.materializeNextTx(ctx, tx, *issue.RecurrenceID, - *issue.OccurrenceKey, actor); err != nil { + *issue.OccurrenceKey, p.Actor); err != nil { return db.Issue{}, nil, false, fmt.Errorf("materialize next recurrence: %w", err) } generated, err := eventsAfterTx(ctx, tx, lastEventID) @@ -1576,7 +1590,7 @@ func (d *Store) closeIssueWithEvents( } events = append(events, generated...) } - updated, err := issueByIDTx(ctx, tx, issueID) + updated, err := issueByIDTx(ctx, tx, p.IssueID) if err != nil { return db.Issue{}, nil, false, err } diff --git a/internal/db/sqlitestore/queries_idempotency.go b/internal/db/sqlitestore/queries_idempotency.go index cf6af80e2..cb8303bae 100644 --- a/internal/db/sqlitestore/queries_idempotency.go +++ b/internal/db/sqlitestore/queries_idempotency.go @@ -128,6 +128,15 @@ func fingerprintCore(title, body string, owner *string, labels []string, sortedL // at-or-after `since`. Returns nil when no match. Uses the partial index // idx_events_idempotency declared in 0001_init.sql. func (d *Store) LookupIdempotency(ctx context.Context, projectID int64, key string, since time.Time) (*db.IdempotencyMatch, error) { + return d.LookupIssueMutationIdempotency(ctx, projectID, "issue.created", key, since) +} + +// LookupIssueMutationIdempotency finds the newest recent issue event of the +// requested type carrying key. Event types are bound query values so callers +// can use the same retry contract for additional issue mutations. +func (d *Store) LookupIssueMutationIdempotency( + ctx context.Context, projectID int64, eventType, key string, since time.Time, +) (*db.IdempotencyMatch, error) { const q = ` SELECT e.id, e.uid, e.origin_instance_uid, e.project_id, p.uid, e.project_name, e.issue_id, e.issue_uid, @@ -136,13 +145,13 @@ func (d *Store) LookupIdempotency(ctx context.Context, projectID int64, key stri json_extract(e.payload, '$.idempotency_fingerprint') FROM events e JOIN projects p ON p.id = e.project_id - WHERE e.type = 'issue.created' + WHERE e.type = ? AND e.project_id = ? AND json_extract(e.payload, '$.idempotency_key') = ? AND e.created_at >= ? ORDER BY e.id DESC LIMIT 1` - row := d.QueryRowContext(ctx, q, projectID, key, since.UTC().Format(sqliteTimeFormat)) + row := d.QueryRowContext(ctx, q, eventType, projectID, key, since.UTC().Format(sqliteTimeFormat)) var ( evt db.Event @@ -155,18 +164,18 @@ func (d *Store) LookupIdempotency(ctx context.Context, projectID int64, key stri return nil, nil } if err != nil { - return nil, fmt.Errorf("lookup idempotency: %w", err) + return nil, fmt.Errorf("lookup issue mutation idempotency: %w", err) } if evt.IssueID == nil { // Defensive: an issue.created event without an issue_id is malformed. - return nil, fmt.Errorf("idempotency match has no issue_id") + return nil, fmt.Errorf("issue mutation idempotency match has no issue_id") } // Carveout (spec §6): idempotency-key collision detection sees the issue // even if it has been soft-deleted, so it can report the right mismatch. // IssueByID returns rows regardless of deleted_at, matching that intent. issue, err := d.IssueByID(ctx, *evt.IssueID) if err != nil { - return nil, fmt.Errorf("idempotency match issue: %w", err) + return nil, fmt.Errorf("issue mutation idempotency match issue: %w", err) } return &db.IdempotencyMatch{ IssueID: issue.ID, diff --git a/internal/db/storage.go b/internal/db/storage.go index e637ff36b..801ab1b02 100644 --- a/internal/db/storage.go +++ b/internal/db/storage.go @@ -76,6 +76,7 @@ type Storage interface { EditIssueAtomic(ctx context.Context, p EditIssueAtomicParams) (EditIssueAtomicResult, error) CloseIssue(ctx context.Context, issueID int64, reason, actor, message string, evidence []Evidence) (Issue, *Event, bool, error) CloseIssueWithEvents(ctx context.Context, issueID int64, reason, actor, message string, evidence []Evidence) (Issue, []Event, bool, error) + CloseIssueGuarded(ctx context.Context, p CloseIssueParams) (Issue, []Event, bool, error) ReopenIssue(ctx context.Context, issueID int64, actor string) (Issue, *Event, bool, error) SoftDeleteIssue(ctx context.Context, issueID int64, actor string) (Issue, *Event, bool, error) RestoreIssue(ctx context.Context, issueID int64, actor string) (Issue, *Event, bool, error) @@ -140,11 +141,12 @@ type Storage interface { MaxEventID(ctx context.Context) (int64, error) MaxLocalOriginEventID(ctx context.Context, projectID int64) (int64, error) MaxFederationBaselineEventID(ctx context.Context, projectID, sinceEventID int64) (int64, error) - // AcquireIdempotencyLock serializes one project/key create decision until + // AcquireIdempotencyLock serializes one project/key mutation decision until // the returned release function runs. Implementations must coordinate every // daemon that can write the same backend, not only goroutines in one server. AcquireIdempotencyLock(ctx context.Context, projectID int64, key string) (release func() error, err error) LookupIdempotency(ctx context.Context, projectID int64, key string, since time.Time) (*IdempotencyMatch, error) + LookupIssueMutationIdempotency(ctx context.Context, projectID int64, eventType, key string, since time.Time) (*IdempotencyMatch, error) LookupCommentIdempotency(ctx context.Context, projectID int64, key string, since time.Time) (*CommentIdempotencyMatch, error) InsertCloseThrottledEvent(ctx context.Context, issueID int64, actor string, payload CloseThrottledPayload) (Event, error) RecentSiblingCloses(ctx context.Context, parentIssueID, excludeIssueID int64, actor string, since time.Time) ([]Event, error) diff --git a/internal/db/types.go b/internal/db/types.go index 1aa9e257b..d9b13eb23 100644 --- a/internal/db/types.go +++ b/internal/db/types.go @@ -554,9 +554,9 @@ type SearchCandidate struct { MatchedIn []string `json:"matched_in"` } -// IdempotencyMatch is the payload returned by LookupIdempotency. The Event row -// is included so the handler can populate `original_event` in the reuse-case -// MutationResponse without a second query. +// IdempotencyMatch is a previously committed issue mutation matched through +// its event payload. The Event row lets handlers return the original mutation +// receipt without a second query. type IdempotencyMatch struct { IssueID int64 IssueShortID string diff --git a/pkg/client/generated/client_options.go b/pkg/client/generated/client_options.go index 81a473c59..1c17ea8f6 100644 --- a/pkg/client/generated/client_options.go +++ b/pkg/client/generated/client_options.go @@ -2446,6 +2446,7 @@ func (o *ClaimIssueRequestOptions) GetHeader() (map[string]string, error) { type CloseIssueRequestOptions struct { PathParams *CloseIssuePath Body *CloseIssueBody + Header *CloseIssueHeaders } // Validate validates all the fields in the options. @@ -2468,6 +2469,14 @@ func (o *CloseIssueRequestOptions) Validate() error { } } } + + if o.Header != nil { + if v, ok := any(o.Header).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Header", err) + } + } + } if len(errors) == 0 { return nil } @@ -2492,7 +2501,7 @@ func (o *CloseIssueRequestOptions) GetBody() any { // GetHeader returns the headers as a map. func (o *CloseIssueRequestOptions) GetHeader() (map[string]string, error) { - return nil, nil + return runtime.AsMap[string](o.Header) } // DeleteIssueRequestOptions is the options needed to make a request to DeleteIssue. diff --git a/pkg/client/generated/headers.go b/pkg/client/generated/headers.go index cbb7af0ab..117b24d15 100644 --- a/pkg/client/generated/headers.go +++ b/pkg/client/generated/headers.go @@ -43,6 +43,11 @@ type CreateIssueHeaders struct { IdempotencyKey *string `json:"Idempotency-Key,omitempty"` } +type CloseIssueHeaders struct { + IdempotencyKey *string `json:"Idempotency-Key,omitempty"` + IfMatch *string `json:"If-Match,omitempty"` +} + type DeleteIssueHeaders struct { XKataConfirm *string `json:"X-Kata-Confirm,omitempty"` } diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml index 1ffc53c8b..acf3de5f8 100644 --- a/pkg/client/openapi.yaml +++ b/pkg/client/openapi.yaml @@ -6031,6 +6031,14 @@ paths: required: true schema: type: string + - in: header + name: Idempotency-Key + schema: + type: string + - in: header + name: If-Match + schema: + type: string requestBody: content: application/json: From a47287683e5da913abf21effa8e8d9ac90bcf541 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 09:44:18 -0500 Subject: [PATCH 02/14] Close retry gaps found in review --- cmd/kata/close.go | 8 ++- cmd/kata/close_reopen_test.go | 18 ++++++ cmd/kata/comment_flag.go | 18 +++++- docs/reference/mcp.md | 5 +- internal/daemon/handlers_actions.go | 4 ++ .../daemon/handlers_actions_retry_test.go | 24 +++++++ internal/db/dbtest/conformance_core.go | 14 ++++ internal/db/pgstore/issue_lifecycle.go | 6 +- internal/db/sqlitestore/queries.go | 6 +- internal/mcp/close_retry_test.go | 64 +++++++++++++++++++ internal/mcp/handlers.go | 21 +++++- internal/mcp/server.go | 12 ++-- web/src/lib/api/schema.d.ts | 5 +- 13 files changed, 188 insertions(+), 17 deletions(-) create mode 100644 internal/mcp/close_retry_test.go diff --git a/cmd/kata/close.go b/cmd/kata/close.go index 6fcf56575..c2c113af9 100644 --- a/cmd/kata/close.go +++ b/cmd/kata/close.go @@ -263,7 +263,13 @@ func runActionWithHeaders( if status >= 400 { return apiErrFromBody(status, bs) } - if err := postFollowupComment(ctx, client, baseURL, pid, issue.RefForAPI, actor, comment); err != nil { + commentKey := "" + if key := headers["Idempotency-Key"]; key != "" { + commentKey = "close-comment:" + key + } + if err := postFollowupCommentWithKey( + ctx, client, baseURL, pid, issue.RefForAPI, actor, comment, commentKey, + ); err != nil { return err } return printMutation(cmd, bs) diff --git a/cmd/kata/close_reopen_test.go b/cmd/kata/close_reopen_test.go index fe783d368..5924a1b29 100644 --- a/cmd/kata/close_reopen_test.go +++ b/cmd/kata/close_reopen_test.go @@ -435,6 +435,24 @@ func TestClose_WithComment_AppendsComment(t *testing.T) { assert.Equal(t, "closed", got.Issue.Status) } +func TestClose_RetryWithCommentAppendsOneComment(t *testing.T) { + env, dir, pid, ref := setupWorkspaceWithIssue(t, "test issue") + args := []string{ + "close", ref, + "--done", + "--message", "Implemented the requested behavior and ran the focused tests.", + "--test", "go test ./cmd/kata", + "--comment", "fixed in abc1234", + "--idempotency-key", "close-request-with-comment-1", + } + runCLI(t, env, dir, args...) + runCLI(t, env, dir, args...) + + got := fetchIssueViaHTTPWithComments(t, env, pid, ref) + require.Len(t, got.Comments, 1) + assert.Equal(t, "fixed in abc1234", got.Comments[0].Body) +} + func TestReopen_WithComment_AppendsComment(t *testing.T) { env, dir, pid, ref := setupWorkspaceWithIssue(t, "test issue") runCLI(t, env, dir, "close", ref, diff --git a/cmd/kata/comment_flag.go b/cmd/kata/comment_flag.go index f9df0e085..aca08aff7 100644 --- a/cmd/kata/comment_flag.go +++ b/cmd/kata/comment_flag.go @@ -47,13 +47,27 @@ func postFollowupComment( baseURL string, projectID int64, issueRef, actor, body string, +) error { + return postFollowupCommentWithKey(ctx, client, baseURL, projectID, issueRef, actor, body, "") +} + +func postFollowupCommentWithKey( + ctx context.Context, + client *http.Client, + baseURL string, + projectID int64, + issueRef, actor, body, idempotencyKey string, ) error { if body == "" { return nil } - status, bs, err := httpDoJSON(ctx, client, http.MethodPost, + headers := map[string]string{} + if idempotencyKey != "" { + headers["Idempotency-Key"] = idempotencyKey + } + status, bs, err := httpDoJSONWithHeader(ctx, client, http.MethodPost, fmt.Sprintf("%s/api/v1/projects/%d/issues/%s/comments", baseURL, projectID, url.PathEscape(issueRef)), - map[string]any{"actor": actor, "body": body}) + headers, map[string]any{"actor": actor, "body": body}) if err != nil { return fmt.Errorf("issue mutation succeeded but appending --comment failed: %w "+ "(retry with: kata comment %s --body ...)", err, issueRef) diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index cb397822a..3f4bf4033 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -171,7 +171,10 @@ The tools use structured input and output. List-like results, including timestamps cannot skip or repeat rows and a project merge or issue purge during pagination fails the page with a restart error instead of silently skewing it. `kata.show` returns at most 100 comments. Create and -comment require idempotency keys. `kata.token_create`, recurrence creation, +comment require idempotency keys. `kata.close` accepts an optional +`idempotency_key` for safe retries and an optional `revision` for a conditional +close. An exact retry returns the original close event in the mutation output. +`kata.token_create`, recurrence creation, `kata.storage_import`, `kata.lease`, and `kata.sync_once` are annotated non-idempotent: the first two mint a new record on every identical retry, a forced storage import replaces the target again (with a fresh instance diff --git a/internal/daemon/handlers_actions.go b/internal/daemon/handlers_actions.go index 335524b0c..883e16709 100644 --- a/internal/daemon/handlers_actions.go +++ b/internal/daemon/handlers_actions.go @@ -82,6 +82,10 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { return reuse, nil } } + if ifMatchRev != nil && issue.Revision != *ifMatchRev { + return nil, api.NewError(412, "revision_conflict", + fmt.Sprintf("issue revision is %d", issue.Revision), "", nil) + } // Already-closed short-circuit. CloseIssue itself returns // changed=false for this case; short-circuiting before the // guards (and substance / evidence validation) keeps idempotent diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go index 8076c2b13..f01bb33f4 100644 --- a/internal/daemon/handlers_actions_retry_test.go +++ b/internal/daemon/handlers_actions_retry_test.go @@ -107,3 +107,27 @@ func TestClose_IfMatchRejectsStaleRevision(t *testing.T) { require.NoError(t, err) assert.Equal(t, "open", issue.Status) } + +func TestClose_IfMatchRejectsStaleRevisionAfterAnotherClose(t *testing.T) { + h, ts, projectID, issueID := bootstrapProjectWithIssue(t) + _, err := h.DB().PatchIssueMetadata(context.Background(), db.PatchIssueMetadataIn{ + IssueID: issueID, + Actor: "coordinator", + Patch: map[string]json.RawMessage{ + "work.state": json.RawMessage(`"ready"`), + }, + }) + require.NoError(t, err) + path := issueURL(projectID, issueID, "actions/close") + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + } + requireOK(t, postWithHeader(t, ts, path, nil, body)) + + response := postWithHeader(t, ts, path, + map[string]string{"If-Match": `"rev-1"`}, body) + assertAPIError(t, response.status, response.body, + http.StatusPreconditionFailed, "revision_conflict") +} diff --git a/internal/db/dbtest/conformance_core.go b/internal/db/dbtest/conformance_core.go index 52f9183de..70f97d841 100644 --- a/internal/db/dbtest/conformance_core.go +++ b/internal/db/dbtest/conformance_core.go @@ -385,6 +385,20 @@ func checkIdempotency(t *testing.T, store db.Storage) error { return fmt.Errorf("guarded close returned %v, want revision conflict", err) } assert.Equal(t, staleIssue.Revision+1, conflict.CurrentRevision) + if _, _, _, err := store.CloseIssueWithEvents( + ctx, staleIssue.ID, "wontfix", "conformance-agent", "stopped", nil, + ); err != nil { + return fmt.Errorf("close after revision conflict: %w", err) + } + _, _, _, err = store.CloseIssueGuarded(ctx, db.CloseIssueParams{ + IssueID: staleIssue.ID, Reason: "wontfix", Actor: "conformance-agent", + IfMatchRev: new(staleIssue.Revision), + }) + conflict, ok = errors.AsType[*db.RevisionConflictError](err) + if !ok || conflict == nil { + return fmt.Errorf("guarded closed-issue retry returned %v, want revision conflict", err) + } + assert.Equal(t, staleIssue.Revision+1, conflict.CurrentRevision) releaseFirst, err := store.AcquireIdempotencyLock(ctx, project.ID, "serialized-request") if err != nil { diff --git a/internal/db/pgstore/issue_lifecycle.go b/internal/db/pgstore/issue_lifecycle.go index 4b46981fc..7e92c2fc5 100644 --- a/internal/db/pgstore/issue_lifecycle.go +++ b/internal/db/pgstore/issue_lifecycle.go @@ -278,13 +278,13 @@ func (s *Store) closeIssueWithEvents( if err != nil { return err } + if p.IfMatchRev != nil && current.Revision != *p.IfMatchRev { + return &db.RevisionConflictError{CurrentRevision: current.Revision} + } if current.Status == "closed" { issue = current return nil } - if p.IfMatchRev != nil && current.Revision != *p.IfMatchRev { - return &db.RevisionConflictError{CurrentRevision: current.Revision} - } var hasOpenChildren bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS( SELECT 1 FROM links l JOIN issues child ON child.id = l.from_issue_id diff --git a/internal/db/sqlitestore/queries.go b/internal/db/sqlitestore/queries.go index 78cc71320..48a3f3c52 100644 --- a/internal/db/sqlitestore/queries.go +++ b/internal/db/sqlitestore/queries.go @@ -1484,15 +1484,15 @@ func (d *Store) closeIssueGuarded( if err != nil { return db.Issue{}, nil, false, err } + if p.IfMatchRev != nil && issue.Revision != *p.IfMatchRev { + return db.Issue{}, nil, false, &db.RevisionConflictError{CurrentRevision: issue.Revision} + } if issue.Status == "closed" { if err := tx.Commit(); err != nil { return db.Issue{}, nil, false, err } return issue, nil, false, nil } - if p.IfMatchRev != nil && issue.Revision != *p.IfMatchRev { - return db.Issue{}, nil, false, &db.RevisionConflictError{CurrentRevision: issue.Revision} - } if hasOpen, err := txHasOpenChildren(ctx, tx, p.IssueID); err != nil { return db.Issue{}, nil, false, err } else if hasOpen { diff --git a/internal/mcp/close_retry_test.go b/internal/mcp/close_retry_test.go new file mode 100644 index 000000000..0a620012d --- /dev/null +++ b/internal/mcp/close_retry_test.go @@ -0,0 +1,64 @@ +package mcpserver + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { + var idempotencyKey, ifMatch string + client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == "/api/v1/projects": + writeJSON(writer, map[string]any{"projects": []any{ + projectJSON(1, "01HAAAAAAAAAAAAAAAAAAAAAAA", "spoke-project"), + }}) + case strings.HasSuffix(request.URL.Path, "/actions/close"): + idempotencyKey = request.Header.Get("Idempotency-Key") + ifMatch = request.Header.Get("If-Match") + issue := issueJSON(1, "spoke-project", "abc1") + issue["status"] = "closed" + writeJSON(writer, map[string]any{ + "issue": issue, + "event": nil, + "original_event": closeRetryEventJSON(), + "changed": false, + "reused": true, + }) + default: + http.NotFound(writer, request) + } + }) + handlers := toolHandlers{options: Options{ + Client: client, Scope: NewAllScope(), Actor: "example-agent", + }} + revision := int64(7) + _, output, err := handlers.close(t.Context(), nil, CloseInput{ + Ref: "spoke-project#abc1", Reason: "wontfix", + Message: "Reviewed the request and recorded why the work should stop here.", + IdempotencyKey: "close-request-1", Revision: &revision, + }) + require.NoError(t, err) + require.Equal(t, "close-request-1", idempotencyKey) + require.Equal(t, `"rev-7"`, ifMatch) + require.NotNil(t, output.Reused) + require.True(t, *output.Reused) + require.NotNil(t, output.Event) + require.Equal(t, "01HCCCCCCCCCCCCCCCCCCCCCCC", output.Event.UID) +} + +func closeRetryEventJSON() map[string]any { + return map[string]any{ + "id": 9, "uid": "01HCCCCCCCCCCCCCCCCCCCCCCC", + "origin_instance_uid": "01HDDDDDDDDDDDDDDDDDDDDDDD", + "project_id": 1, "project_uid": "01HAAAAAAAAAAAAAAAAAAAAAAA", + "project_name": "spoke-project", "type": "issue.closed", + "actor": "example-agent", "payload": `{}`, + "hlc_physical_ms": 1, "hlc_counter": 0, + "content_hash": strings.Repeat("a", 64), + "created_at": "2026-08-11T00:00:00Z", + } +} diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 88829c1c8..fa6c15b7f 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -772,6 +772,20 @@ func (h toolHandlers) close(ctx context.Context, _ *sdkmcp.CallToolRequest, inpu } evidence = append(evidence, converted) } + var headers *generated.CloseIssueHeaders + if input.IdempotencyKey != "" || input.Revision != nil { + headers = &generated.CloseIssueHeaders{} + if input.IdempotencyKey != "" { + headers.IdempotencyKey = &input.IdempotencyKey + } + if input.Revision != nil { + if *input.Revision < 0 { + return nil, MutationOutput{}, errors.New("revision must not be negative") + } + ifMatch := `"rev-` + strconv.FormatInt(*input.Revision, 10) + `"` + headers.IfMatch = &ifMatch + } + } response, err := h.options.Client.CloseIssue(ctx, &generated.CloseIssueRequestOptions{ PathParams: &generated.CloseIssuePath{ProjectID: project.ID, Ref: ref}, Body: &generated.CloseIssueBody{ @@ -781,11 +795,16 @@ func (h toolHandlers) close(ctx context.Context, _ *sdkmcp.CallToolRequest, inpu Evidence: evidence, DryRun: optionalTrue(input.DryRun), }, + Header: headers, }) if err != nil { return nil, MutationOutput{}, h.scopedCloseError(err) } - return successResult(), h.mutation(project, response.Issue, response.Changed, response.Reused, &response.Event), nil + event := &response.Event + if response.OriginalEvent != nil { + event = response.OriginalEvent + } + return successResult(), h.mutation(project, response.Issue, response.Changed, response.Reused, event), nil } // Close-guard refusals render child, sibling-cohort, and prior-close diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 720e1c694..e067dc90c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -663,11 +663,13 @@ type Evidence struct { // CloseInput makes the completion assertion explicit. type CloseInput struct { - Ref string `json:"ref" jsonschema:"Issue reference; use project#ref in multi-project mode"` - Reason string `json:"reason" jsonschema:"Close reason: done, wontfix, duplicate, superseded, or audit-no-change"` - Message string `json:"message" jsonschema:"Substantive completion message"` - Evidence []Evidence `json:"evidence" jsonschema:"Typed evidence that supports the close"` - DryRun bool `json:"dry_run,omitempty" jsonschema:"Validate the close without changing the issue"` + Ref string `json:"ref" jsonschema:"Issue reference; use project#ref in multi-project mode"` + Reason string `json:"reason" jsonschema:"Close reason: done, wontfix, duplicate, superseded, or audit-no-change"` + Message string `json:"message" jsonschema:"Substantive completion message"` + Evidence []Evidence `json:"evidence" jsonschema:"Typed evidence that supports the close"` + DryRun bool `json:"dry_run,omitempty" jsonschema:"Validate the close without changing the issue"` + IdempotencyKey string `json:"idempotency_key,omitempty" jsonschema:"Stable unique key for safe retries"` + Revision *int64 `json:"revision,omitempty" jsonschema:"Expected current issue revision for a conditional close"` } // ReopenInput reactivates closed work. diff --git a/web/src/lib/api/schema.d.ts b/web/src/lib/api/schema.d.ts index 3eeca3bba..6cebc17e2 100644 --- a/web/src/lib/api/schema.d.ts +++ b/web/src/lib/api/schema.d.ts @@ -5354,7 +5354,10 @@ export interface operations { closeIssue: { parameters: { query?: never - header?: never + header?: { + 'Idempotency-Key'?: string + 'If-Match'?: string + } path: { project_id: number ref: string From a36051239706c2e6a405d609a1788bd3e69c528b Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 11:39:29 -0500 Subject: [PATCH 03/14] Gate close retries across version skew --- api/openapi.yaml | 2 +- cmd/kata/api_compat.go | 1 + cmd/kata/api_compat_test.go | 29 ++++++++++++++++ cmd/kata/close.go | 6 ++++ cmd/kata/mcp.go | 22 ++++++++----- docs/reference/http-api.md | 3 +- internal/daemon/handlers_actions.go | 9 ++++- .../daemon/handlers_actions_retry_test.go | 33 +++++++++++++++++++ internal/daemon/openapi.go | 2 +- internal/daemon/openapi_test.go | 6 ++-- internal/mcp/close_retry_test.go | 28 ++++++++++++++++ internal/mcp/handlers.go | 3 ++ internal/mcp/server.go | 19 ++++++----- pkg/client/openapi.yaml | 2 +- 14 files changed, 139 insertions(+), 26 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index a9543fbd1..53e7d8124 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4591,7 +4591,7 @@ components: type: object info: title: kata - version: 0.14.0 + version: 0.15.0 openapi: 3.1.0 paths: /api/v1/audit/closes: diff --git a/cmd/kata/api_compat.go b/cmd/kata/api_compat.go index 2397c0261..7d3a42567 100644 --- a/cmd/kata/api_compat.go +++ b/cmd/kata/api_compat.go @@ -12,6 +12,7 @@ import ( const ( apiVersionReadyAndSearchFilters = "0.8.0" apiVersionGlobalListFilters = "0.9.0" + apiVersionCloseRetrySafety = "0.15.0" // apiVersionMCPServer is the oldest daemon the native MCP server can // drive: it pins relationship targets with to_project_uid and // expected_project_uids and pages audit rows by event_id. diff --git a/cmd/kata/api_compat_test.go b/cmd/kata/api_compat_test.go index 9f5d19ee1..4b09fa76f 100644 --- a/cmd/kata/api_compat_test.go +++ b/cmd/kata/api_compat_test.go @@ -86,6 +86,35 @@ func TestFilteredListAllRejectsDaemonBeforeGlobalListFilters(t *testing.T) { assert.Zero(t, listCalls.Load(), "the unfiltered old endpoint must not be queried") } +func TestCloseRetryFlagsRejectOldDaemonBeforeClose(t *testing.T) { + var closeCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/projects/resolve": + _, _ = w.Write([]byte(`{"project":{"id":1,"name":"example-project"}}`)) + case "/api/v1/health": + _, _ = w.Write([]byte(`{"ok":true,"api_schema_version":"0.14.0"}`)) + case "/api/v1/projects/1/issues/abc1/actions/close": + closeCalls.Add(1) + _, _ = w.Write([]byte(`{"changed":true}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + _, _, err := executeRootCapture(t, + contextWithBaseURL(context.Background(), server.URL), + "--project", "example-project", "close", "abc1", + "--wontfix", + "--message", "Reviewed the request and recorded why the work should stop here.", + "--idempotency-key", "close-request-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires daemon API 0.15.0 or newer") + assert.Contains(t, err.Error(), "reports 0.14.0") + assert.Zero(t, closeCalls.Load(), "an old daemon must not receive retry headers") +} + func TestListAllDefaultsToUnlimited(t *testing.T) { var sentLimit atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/kata/close.go b/cmd/kata/close.go index c2c113af9..9e8234d12 100644 --- a/cmd/kata/close.go +++ b/cmd/kata/close.go @@ -254,6 +254,12 @@ func runActionWithHeaders( if err != nil { return err } + if action == "close" && len(headers) > 0 { + if err := requireDaemonAPIVersion(ctx, client, baseURL, + apiVersionCloseRetrySafety, "close retry controls"); err != nil { + return err + } + } status, bs, err := httpDoJSONHeaders(ctx, client, http.MethodPost, fmt.Sprintf("%s/api/v1/projects/%d/issues/%s/actions/%s", baseURL, pid, url.PathEscape(issue.RefForAPI), action), body, headers) diff --git a/cmd/kata/mcp.go b/cmd/kata/mcp.go index 588e3fc96..c6c93490a 100644 --- a/cmd/kata/mcp.go +++ b/cmd/kata/mcp.go @@ -98,6 +98,9 @@ func newMCPServeCmd() *cobra.Command { if err != nil { return err } + closeRetryHeadersSupported, _ := apiVersionAtLeast( + health.APISchemaVersion, apiVersionCloseRetrySafety, + ) stopKeepalive := startMCPIdleKeepalive(ctx, httpClient, baseURL, health) defer stopKeepalive() actor, _ := resolveActor(ctx, flags.As, nil) @@ -142,15 +145,16 @@ func newMCPServeCmd() *cobra.Command { return fmt.Errorf("resolve MCP project scope: %w", err) } server, err := mcpserver.New(mcpserver.Options{ - Client: apiClient, - LongRunningClient: longRunningAPIClient, - Scope: scope, - ProjectID: projectID, - ProjectName: projectName, - Actor: actor, - Version: version.Version, - StorageAdmin: storage, - EnableTokenAdmin: enableTokenAdmin, + Client: apiClient, + LongRunningClient: longRunningAPIClient, + Scope: scope, + ProjectID: projectID, + ProjectName: projectName, + Actor: actor, + Version: version.Version, + StorageAdmin: storage, + EnableTokenAdmin: enableTokenAdmin, + CloseRetryHeadersSupported: closeRetryHeadersSupported, }) if err != nil { return err diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 1d3cd5707..226e3e440 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -45,7 +45,7 @@ The schema carries a version in its `info.version` field { "ok": true, "schema_version": 7, - "api_schema_version": "0.14.0", + "api_schema_version": "0.15.0", "version": "1.4.2", "uptime": "5m0s", "db_path": "/path/to/kata.db", @@ -107,6 +107,7 @@ and decline to render issue detail. | Version | Change | | --- | --- | +| `0.15.0` | Added close idempotency and revision headers plus retry receipt fields. Clients must check this version before sending the new headers because older daemons ignore them. | | `0.14.0` | Added `external` close evidence with its required `account` field. | | `0.13.0` | Added optimistic revision guards to issue and project metadata patch requests. | | `0.12.0` | Added the optional `idle_shutdown` health block for effective auto-start idle shutdown state and capability discovery. | diff --git a/internal/daemon/handlers_actions.go b/internal/daemon/handlers_actions.go index 883e16709..f9721c5ea 100644 --- a/internal/daemon/handlers_actions.go +++ b/internal/daemon/handlers_actions.go @@ -58,7 +58,11 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { // so duplicate/superseded closes still must carry their typed // targets and won't corrupt the audit trail. tuiBypass := tuiBypassAllowed(ctx, in.Body.Source, in.Body.Reason) - issue, err := activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, db.IncludeDeletedNo) + includeDeleted := db.IncludeDeletedNo + if in.IdempotencyKey != "" { + includeDeleted = db.IncludeDeletedYes + } + issue, err := activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, includeDeleted) if err != nil { return nil, err } @@ -82,6 +86,9 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { return reuse, nil } } + if issue.DeletedAt != nil { + return nil, api.NewError(404, "issue_not_found", "issue not found", "", nil) + } if ifMatchRev != nil && issue.Revision != *ifMatchRev { return nil, api.NewError(412, "revision_conflict", fmt.Sprintf("issue revision is %d", issue.Revision), "", nil) diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go index f01bb33f4..58b636653 100644 --- a/internal/daemon/handlers_actions_retry_test.go +++ b/internal/daemon/handlers_actions_retry_test.go @@ -63,6 +63,39 @@ func TestClose_IdempotencyReplaysCommittedReceipt(t *testing.T) { assert.Equal(t, 1, closed) } +func TestClose_IdempotencyReplaysReceiptAfterSoftDelete(t *testing.T) { + h, ts, projectID, issueID := bootstrapProjectWithIssue(t) + issue, err := h.DB().IssueByID(context.Background(), issueID) + require.NoError(t, err) + path := issueURLRef(projectID, issue.ShortID, "actions/close") + headers := map[string]string{"Idempotency-Key": "close-request-then-delete"} + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + } + + first := postWithHeader(t, ts, path, headers, body) + requireOK(t, first) + var firstOut api.MutationResponse + require.NoError(t, json.Unmarshal(first.body, &firstOut.Body)) + require.NotNil(t, firstOut.Body.Event) + + _, _, changed, err := h.DB().SoftDeleteIssue(context.Background(), issueID, "agent-one") + require.NoError(t, err) + require.True(t, changed) + + retry := postWithHeader(t, ts, path, headers, body) + requireOK(t, retry) + var retryOut api.MutationResponse + require.NoError(t, json.Unmarshal(retry.body, &retryOut.Body)) + assert.False(t, retryOut.Body.Changed) + assert.True(t, retryOut.Body.Reused) + require.NotNil(t, retryOut.Body.OriginalEvent) + assert.Equal(t, firstOut.Body.Event.UID, retryOut.Body.OriginalEvent.UID) + require.NotNil(t, retryOut.Body.Issue.DeletedAt) +} + func TestClose_IdempotencyRejectsDifferentRequest(t *testing.T) { _, ts, projectID, issueID := bootstrapProjectWithIssue(t) path := issueURL(projectID, issueID, "actions/close") diff --git a/internal/daemon/openapi.go b/internal/daemon/openapi.go index ebc26e524..41cf3d25d 100644 --- a/internal/daemon/openapi.go +++ b/internal/daemon/openapi.go @@ -16,7 +16,7 @@ import ( // (info.version). It tracks the HTTP API contract, not the build version, so // the committed schema artifact stays stable across builds and is bumped // deliberately when the wire contract changes. -const APISchemaVersion = "0.14.0" +const APISchemaVersion = "0.15.0" // OpenAPIDocument builds the daemon's complete OpenAPI model by wiring every // route through NewServer with a zero ServerConfig. It binds no listener and diff --git a/internal/daemon/openapi_test.go b/internal/daemon/openapi_test.go index 5d66308f5..f95a8d8d2 100644 --- a/internal/daemon/openapi_test.go +++ b/internal/daemon/openapi_test.go @@ -287,9 +287,9 @@ func TestOpenAPIDocumentIncludesUIReadContract(t *testing.T) { } } -func TestOpenAPISchemaVersionReflectsExternalEvidence(t *testing.T) { - if APISchemaVersion != "0.14.0" { - t.Fatalf("APISchemaVersion = %q, want 0.14.0 for external evidence", APISchemaVersion) +func TestOpenAPISchemaVersionReflectsCloseRetrySafety(t *testing.T) { + if APISchemaVersion != "0.15.0" { + t.Fatalf("APISchemaVersion = %q, want 0.15.0 for close retry safety", APISchemaVersion) } } diff --git a/internal/mcp/close_retry_test.go b/internal/mcp/close_retry_test.go index 0a620012d..2eabf8899 100644 --- a/internal/mcp/close_retry_test.go +++ b/internal/mcp/close_retry_test.go @@ -34,6 +34,7 @@ func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { }) handlers := toolHandlers{options: Options{ Client: client, Scope: NewAllScope(), Actor: "example-agent", + CloseRetryHeadersSupported: true, }} revision := int64(7) _, output, err := handlers.close(t.Context(), nil, CloseInput{ @@ -50,6 +51,33 @@ func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { require.Equal(t, "01HCCCCCCCCCCCCCCCCCCCCCCC", output.Event.UID) } +func TestCloseRejectsRetryGuardsWhenDaemonDoesNotSupportThem(t *testing.T) { + var closeCalls int + client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == "/api/v1/projects": + writeJSON(writer, map[string]any{"projects": []any{ + projectJSON(1, "01HAAAAAAAAAAAAAAAAAAAAAAA", "spoke-project"), + }}) + case strings.HasSuffix(request.URL.Path, "/actions/close"): + closeCalls++ + writeJSON(writer, map[string]any{"changed": true}) + default: + http.NotFound(writer, request) + } + }) + handlers := toolHandlers{options: Options{ + Client: client, Scope: NewAllScope(), Actor: "example-agent", + }} + _, _, err := handlers.close(t.Context(), nil, CloseInput{ + Ref: "spoke-project#abc1", Reason: "wontfix", + Message: "Reviewed the request and recorded why the work should stop here.", + IdempotencyKey: "close-request-1", + }) + require.ErrorContains(t, err, "daemon does not support kata.close retry controls") + require.Zero(t, closeCalls) +} + func closeRetryEventJSON() map[string]any { return map[string]any{ "id": 9, "uid": "01HCCCCCCCCCCCCCCCCCCCCCCC", diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index fa6c15b7f..e6ec4f626 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -753,6 +753,9 @@ func (h toolHandlers) patchMetadata(ctx context.Context, project ProjectIdentity } func (h toolHandlers) close(ctx context.Context, _ *sdkmcp.CallToolRequest, input CloseInput) (*sdkmcp.CallToolResult, MutationOutput, error) { + if (input.IdempotencyKey != "" || input.Revision != nil) && !h.options.CloseRetryHeadersSupported { + return nil, MutationOutput{}, errors.New("daemon does not support kata.close retry controls; upgrade the daemon") + } project, ref, err := h.options.Scope.IssueTarget(ctx, h.options.Client, input.Ref, true) if err != nil { return nil, MutationOutput{}, err diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e067dc90c..0dba55a11 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -27,15 +27,16 @@ const ( // Options fixes the Kata daemon scope and actor for one MCP process. type Options struct { - Client *kataclient.Client - LongRunningClient *kataclient.Client - Scope *Scope - ProjectID int64 - ProjectName string - Actor string - Version string - StorageAdmin *storageadmin.Admin - EnableTokenAdmin bool + Client *kataclient.Client + LongRunningClient *kataclient.Client + Scope *Scope + ProjectID int64 + ProjectName string + Actor string + Version string + StorageAdmin *storageadmin.Admin + EnableTokenAdmin bool + CloseRetryHeadersSupported bool } // New creates a tools-only MCP server for one startup project scope. diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml index acf3de5f8..042754eec 100644 --- a/pkg/client/openapi.yaml +++ b/pkg/client/openapi.yaml @@ -4359,7 +4359,7 @@ components: type: object info: title: kata - version: 0.14.0 + version: 0.15.0 openapi: 3.0.3 paths: /api/v1/audit/closes: From dbd696a63f3b178e150c3c0aa53d86a60c172af7 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 11:54:59 -0500 Subject: [PATCH 04/14] Keep close retries safe on CLI failures --- cmd/kata/close.go | 7 +++++++ cmd/kata/close_reopen_test.go | 14 ++++++++++++++ cmd/kata/comment_flag.go | 8 ++++++-- cmd/kata/comment_test.go | 14 ++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cmd/kata/close.go b/cmd/kata/close.go index 9e8234d12..c51be0e02 100644 --- a/cmd/kata/close.go +++ b/cmd/kata/close.go @@ -52,6 +52,13 @@ Instead, label and comment: kata comment --body "what was attempted, what remains"`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("idempotency-key") && strings.TrimSpace(idempotencyKey) == "" { + return &cliError{ + Message: "--idempotency-key must not be blank", + Kind: kindValidation, + ExitCode: ExitValidation, + } + } if err := validateMetaIfMatchFlag(cmd, ifMatch); err != nil { return err } diff --git a/cmd/kata/close_reopen_test.go b/cmd/kata/close_reopen_test.go index 5924a1b29..15ce63f95 100644 --- a/cmd/kata/close_reopen_test.go +++ b/cmd/kata/close_reopen_test.go @@ -88,6 +88,20 @@ func TestCloseCmd_RejectsBlankIfMatch(t *testing.T) { assert.Contains(t, stderr, "--if-match must not be blank") } +func TestCloseCmd_RejectsBlankIdempotencyKey(t *testing.T) { + env, dir, _, ref := setupWorkspaceWithIssue(t, "test issue") + _, stderr, err := runCLIWithErr(t, env, dir, + "close", ref, + "--wontfix", + "--message", "Reviewed the request and recorded why the work should stop here.", + "--idempotency-key", " ") + require.Error(t, err) + assert.Contains(t, stderr, "--idempotency-key must not be blank") + + show := runCLI(t, env, dir, "show", ref, "--json") + assert.Contains(t, show, `"status":"open"`) +} + func TestClose_AgentDryRunSuppressesHumanBanner(t *testing.T) { env, dir, _, ref := setupWorkspaceWithIssue(t, "test issue") diff --git a/cmd/kata/comment_flag.go b/cmd/kata/comment_flag.go index aca08aff7..499ade112 100644 --- a/cmd/kata/comment_flag.go +++ b/cmd/kata/comment_flag.go @@ -65,17 +65,21 @@ func postFollowupCommentWithKey( if idempotencyKey != "" { headers["Idempotency-Key"] = idempotencyKey } + retryCommand := fmt.Sprintf("kata comment %s --body ...", issueRef) + if idempotencyKey != "" { + retryCommand += fmt.Sprintf(" --idempotency-key %q", idempotencyKey) + } status, bs, err := httpDoJSONWithHeader(ctx, client, http.MethodPost, fmt.Sprintf("%s/api/v1/projects/%d/issues/%s/comments", baseURL, projectID, url.PathEscape(issueRef)), headers, map[string]any{"actor": actor, "body": body}) if err != nil { return fmt.Errorf("issue mutation succeeded but appending --comment failed: %w "+ - "(retry with: kata comment %s --body ...)", err, issueRef) + "(retry with: %s)", err, retryCommand) } if status >= 400 { base := apiErrFromBody(status, bs) return fmt.Errorf("issue mutation succeeded but appending --comment failed: %w "+ - "(retry with: kata comment %s --body ...)", base, issueRef) + "(retry with: %s)", base, retryCommand) } return nil } diff --git a/cmd/kata/comment_test.go b/cmd/kata/comment_test.go index be2e352d0..06bb5460b 100644 --- a/cmd/kata/comment_test.go +++ b/cmd/kata/comment_test.go @@ -2,6 +2,8 @@ package main import ( "context" + "net/http" + "net/http/httptest" "strings" "testing" @@ -9,6 +11,18 @@ import ( "github.com/stretchr/testify/require" ) +func TestPostFollowupCommentFailurePreservesRetryKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + http.Error(writer, "temporary failure", http.StatusServiceUnavailable) + })) + t.Cleanup(server.Close) + + err := postFollowupCommentWithKey(t.Context(), server.Client(), server.URL, + 1, "abc1", "example-agent", "finished work", "close-comment:close-request-1") + require.Error(t, err) + assert.Contains(t, err.Error(), `kata comment abc1 --body ... --idempotency-key "close-comment:close-request-1"`) +} + func TestComment_AppendsToIssue(t *testing.T) { env, dir := setupCLIEnv(t) short := createIssueViaHTTP(t, env, dir, "x") From fa26c50fa8d7de73f8dccb6e8b85af3dd4c492e8 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 12:05:39 -0500 Subject: [PATCH 05/14] Reject unsafe close recovery paths --- cmd/kata/comment_flag.go | 8 ++++---- cmd/kata/comment_test.go | 4 ++-- internal/api/metadata_resolvers.go | 9 +++++++++ internal/daemon/handlers_actions_retry_test.go | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/cmd/kata/comment_flag.go b/cmd/kata/comment_flag.go index 499ade112..1ea554e8c 100644 --- a/cmd/kata/comment_flag.go +++ b/cmd/kata/comment_flag.go @@ -65,21 +65,21 @@ func postFollowupCommentWithKey( if idempotencyKey != "" { headers["Idempotency-Key"] = idempotencyKey } - retryCommand := fmt.Sprintf("kata comment %s --body ...", issueRef) + retryInstruction := fmt.Sprintf("retry with: kata comment %s --body ...", issueRef) if idempotencyKey != "" { - retryCommand += fmt.Sprintf(" --idempotency-key %q", idempotencyKey) + retryInstruction = "rerun the original kata close command with the same --idempotency-key" } status, bs, err := httpDoJSONWithHeader(ctx, client, http.MethodPost, fmt.Sprintf("%s/api/v1/projects/%d/issues/%s/comments", baseURL, projectID, url.PathEscape(issueRef)), headers, map[string]any{"actor": actor, "body": body}) if err != nil { return fmt.Errorf("issue mutation succeeded but appending --comment failed: %w "+ - "(retry with: %s)", err, retryCommand) + "(%s)", err, retryInstruction) } if status >= 400 { base := apiErrFromBody(status, bs) return fmt.Errorf("issue mutation succeeded but appending --comment failed: %w "+ - "(retry with: %s)", base, retryCommand) + "(%s)", base, retryInstruction) } return nil } diff --git a/cmd/kata/comment_test.go b/cmd/kata/comment_test.go index 06bb5460b..8deb4c22a 100644 --- a/cmd/kata/comment_test.go +++ b/cmd/kata/comment_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestPostFollowupCommentFailurePreservesRetryKey(t *testing.T) { +func TestPostFollowupCommentFailureRecommendsSafeKeyedRetry(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { http.Error(writer, "temporary failure", http.StatusServiceUnavailable) })) @@ -20,7 +20,7 @@ func TestPostFollowupCommentFailurePreservesRetryKey(t *testing.T) { err := postFollowupCommentWithKey(t.Context(), server.Client(), server.URL, 1, "abc1", "example-agent", "finished work", "close-comment:close-request-1") require.Error(t, err) - assert.Contains(t, err.Error(), `kata comment abc1 --body ... --idempotency-key "close-comment:close-request-1"`) + assert.Contains(t, err.Error(), "rerun the original kata close command with the same --idempotency-key") } func TestComment_AppendsToIssue(t *testing.T) { diff --git a/internal/api/metadata_resolvers.go b/internal/api/metadata_resolvers.go index 514d6b268..f0e3cad80 100644 --- a/internal/api/metadata_resolvers.go +++ b/internal/api/metadata_resolvers.go @@ -40,3 +40,12 @@ func (*PatchProjectMetadataRequest) Resolve(ctx huma.Context) []error { } return nil } + +// Resolve rejects an empty close revision guard before the action can be +// mistaken for an unconditional close. +func (*CloseActionRequest) Resolve(ctx huma.Context) []error { + if ifMatchPresentButEmpty(ctx) { + return []error{NewError(400, "validation", "If-Match header required", "", nil)} + } + return nil +} diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go index 58b636653..79047a5c4 100644 --- a/internal/daemon/handlers_actions_retry_test.go +++ b/internal/daemon/handlers_actions_retry_test.go @@ -141,6 +141,23 @@ func TestClose_IfMatchRejectsStaleRevision(t *testing.T) { assert.Equal(t, "open", issue.Status) } +func TestClose_PresentEmptyIfMatchRejectsRequest(t *testing.T) { + h, ts, projectID, issueID := bootstrapProjectWithIssue(t) + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + } + response := postWithHeader(t, ts, issueURL(projectID, issueID, "actions/close"), + map[string]string{"If-Match": ""}, body) + assertAPIError(t, response.status, response.body, + http.StatusBadRequest, "validation") + + issue, err := h.DB().IssueByID(context.Background(), issueID) + require.NoError(t, err) + assert.Equal(t, "open", issue.Status) +} + func TestClose_IfMatchRejectsStaleRevisionAfterAnotherClose(t *testing.T) { h, ts, projectID, issueID := bootstrapProjectWithIssue(t) _, err := h.DB().PatchIssueMetadata(context.Background(), db.PatchIssueMetadataIn{ From 95241c169dddeb2e518b9c5d8fc5b942bba3b7a3 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 15:12:33 -0500 Subject: [PATCH 06/14] Recover close races and ambiguous commits --- internal/daemon/handlers_actions.go | 27 ++++ .../daemon/handlers_actions_retry_test.go | 121 ++++++++++++++++++ internal/db/sqlitestore/queries.go | 4 +- internal/db/storage.go | 3 + 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/internal/daemon/handlers_actions.go b/internal/daemon/handlers_actions.go index f9721c5ea..bcd4fec62 100644 --- a/internal/daemon/handlers_actions.go +++ b/internal/daemon/handlers_actions.go @@ -101,6 +101,11 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { // has landed since the original close, or when the throttle // window is hot. Validation only gates real state transitions. if issue.Status == "closed" { + if in.IdempotencyKey != "" { + return nil, api.NewError(409, "issue_already_closed", + "issue was already closed by another request", + "omit the idempotency key to accept the current state", nil) + } out := &api.MutationResponse{} out.Body.Issue = issue return out, nil @@ -186,6 +191,20 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { return err }) if err != nil { + // A connection can fail after the database commits. The keyed + // receipt proves whether this attempt landed. When it did, publish + // the complete event batch returned by that attempt before replying. + if in.IdempotencyKey != "" { + reuse, lookupErr := tryCloseIdempotencyMatch( + ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint) + if lookupErr != nil { + return nil, lookupErr + } + if reuse != nil && closeEventBatchMatchesReceipt(events, reuse) { + cfg.Publish().Events(in.ProjectID, events) + return reuse, nil + } + } if revisionConflict, ok := errors.AsType[*db.RevisionConflictError](err); ok { return nil, api.NewError(412, "revision_conflict", fmt.Sprintf("issue revision is %d", revisionConflict.CurrentRevision), "", nil) @@ -220,6 +239,9 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { if reuse != nil { return reuse, nil } + return nil, api.NewError(409, "issue_already_closed", + "issue was closed by another request before this close committed", + "retry after reopening, or omit the idempotency key to accept the current state", nil) } if changed { cfg.Publish().Events(in.ProjectID, events) @@ -277,6 +299,11 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { }) } +func closeEventBatchMatchesReceipt(events []db.Event, response *api.MutationResponse) bool { + return len(events) > 0 && response != nil && response.Body.OriginalEvent != nil && + events[0].UID == response.Body.OriginalEvent.UID +} + func tryCloseIdempotencyMatch( ctx context.Context, cfg ServerConfig, diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go index 79047a5c4..dd1d4840c 100644 --- a/internal/daemon/handlers_actions_retry_test.go +++ b/internal/daemon/handlers_actions_retry_test.go @@ -3,16 +3,57 @@ package daemon_test import ( "context" "encoding/json" + "errors" "net/http" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/kata/internal/api" + "go.kenn.io/kata/internal/daemon" "go.kenn.io/kata/internal/db" ) +type closeRaceStore struct { + db.Storage + raceNext bool +} + +func (s *closeRaceStore) CloseIssueGuarded( + ctx context.Context, params db.CloseIssueParams, +) (db.Issue, []db.Event, bool, error) { + if s.raceNext { + s.raceNext = false + _, _, _, err := s.Storage.CloseIssueGuarded(ctx, db.CloseIssueParams{ + IssueID: params.IssueID, Reason: "wontfix", Actor: "racing-agent", + }) + if err != nil { + return db.Issue{}, nil, false, err + } + } + return s.Storage.CloseIssueGuarded(ctx, params) +} + +type lostCloseResponseStore struct { + db.Storage + failNext bool + committedEvents []db.Event +} + +func (s *lostCloseResponseStore) CloseIssueGuarded( + ctx context.Context, params db.CloseIssueParams, +) (db.Issue, []db.Event, bool, error) { + issue, events, changed, err := s.Storage.CloseIssueGuarded(ctx, params) + if err == nil && changed && s.failNext { + s.failNext = false + s.committedEvents = append([]db.Event(nil), events...) + return issue, events, changed, errors.New("commit response lost") + } + return issue, events, changed, err +} + func TestClose_IdempotencyReplaysCommittedReceipt(t *testing.T) { h, ts, projectID, issueID := bootstrapProjectWithIssue(t) issue, err := h.DB().IssueByID(context.Background(), issueID) @@ -96,6 +137,86 @@ func TestClose_IdempotencyReplaysReceiptAfterSoftDelete(t *testing.T) { require.NotNil(t, retryOut.Body.Issue.DeletedAt) } +func TestClose_RejectsKeyWhenAnotherCloseWinsBeforeWrite(t *testing.T) { + database := openTestDB(t) + project, err := database.db.CreateProject(t.Context(), "kata") + require.NoError(t, err) + issue, _, err := database.db.CreateIssue(t.Context(), db.CreateIssueParams{ + ProjectID: project.ID, Title: "race", Author: "agent-one", + }) + require.NoError(t, err) + store := &closeRaceStore{Storage: database.db, raceNext: true} + ts := startTestServer(t, daemon.ServerConfig{DB: store, StartedAt: database.now}) + path := issueURLRef(project.ID, issue.ShortID, "actions/close") + headers := map[string]string{"Idempotency-Key": "close-race-1"} + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + } + + first := postWithHeader(t, ts, path, headers, body) + assertAPIError(t, first.status, first.body, http.StatusConflict, "issue_already_closed") + + _, _, changed, err := database.db.ReopenIssue(t.Context(), issue.ID, "coordinator") + require.NoError(t, err) + require.True(t, changed) + + retry := postWithHeader(t, ts, path, headers, body) + requireOK(t, retry) + var retryOut api.MutationResponse + require.NoError(t, json.Unmarshal(retry.body, &retryOut.Body)) + assert.True(t, retryOut.Body.Changed) + assert.False(t, retryOut.Body.Reused) +} + +func TestClose_RecoversCommittedReceiptAndPublishesEventsOnce(t *testing.T) { + database := openTestDB(t) + project, issue := createClaimHubIssueInDB(t, database.db) + _, err := database.db.AcquireClaim(t.Context(), db.AcquireClaimParams{ + ProjectID: project.ID, IssueRef: issue.ShortID, + Principal: db.ClaimPrincipal{ + HolderInstanceUID: database.db.InstanceUID(), Holder: "agent-one", ClientKind: "cli", + }, + ClaimKind: "hard", Now: time.Now().UTC(), + }) + require.NoError(t, err) + store := &lostCloseResponseStore{Storage: database.db, failNext: true} + sink := &recordingSink{} + broadcaster := daemon.NewEventBroadcaster() + subscription := broadcaster.Subscribe(daemon.SubFilter{ProjectID: project.ID}) + defer subscription.Unsub() + ts := startTestServer(t, daemon.ServerConfig{ + DB: store, StartedAt: database.now, Hooks: sink, Broadcaster: broadcaster, + }) + path := issueURLRef(project.ID, issue.ShortID, "actions/close") + headers := map[string]string{"Idempotency-Key": "close-lost-response-1"} + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + } + + first := postWithHeader(t, ts, path, headers, body) + requireOK(t, first) + var firstOut api.MutationResponse + require.NoError(t, json.Unmarshal(first.body, &firstOut.Body)) + assert.False(t, firstOut.Body.Changed) + assert.True(t, firstOut.Body.Reused) + require.Len(t, store.committedEvents, 2) + assert.Equal(t, []string{"issue.closed", "claim.released"}, []string{ + store.committedEvents[0].Type, store.committedEvents[1].Type, + }) + assert.Equal(t, store.committedEvents, sink.snapshot()) + assert.Equal(t, []int64{store.committedEvents[0].ID, store.committedEvents[1].ID}, + drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) + + second := postWithHeader(t, ts, path, headers, body) + requireOK(t, second) + assert.Equal(t, store.committedEvents, sink.snapshot(), "an exact retry must not publish twice") + assert.Empty(t, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) +} + func TestClose_IdempotencyRejectsDifferentRequest(t *testing.T) { _, ts, projectID, issueID := bootstrapProjectWithIssue(t) path := issueURL(projectID, issueID, "actions/close") diff --git a/internal/db/sqlitestore/queries.go b/internal/db/sqlitestore/queries.go index 48a3f3c52..0931695da 100644 --- a/internal/db/sqlitestore/queries.go +++ b/internal/db/sqlitestore/queries.go @@ -1595,7 +1595,9 @@ func (d *Store) closeIssueGuarded( return db.Issue{}, nil, false, err } if err := tx.Commit(); err != nil { - return db.Issue{}, nil, false, err + // Preserve the attempted result so the daemon can match its keyed + // receipt after an ambiguous commit response and publish every event. + return updated, events, true, err } return updated, events, true, nil } diff --git a/internal/db/storage.go b/internal/db/storage.go index 801ab1b02..55422fd21 100644 --- a/internal/db/storage.go +++ b/internal/db/storage.go @@ -76,6 +76,9 @@ type Storage interface { EditIssueAtomic(ctx context.Context, p EditIssueAtomicParams) (EditIssueAtomicResult, error) CloseIssue(ctx context.Context, issueID int64, reason, actor, message string, evidence []Evidence) (Issue, *Event, bool, error) CloseIssueWithEvents(ctx context.Context, issueID int64, reason, actor, message string, evidence []Evidence) (Issue, []Event, bool, error) + // CloseIssueGuarded preserves its attempted issue, event batch, and changed + // result when commit returns an ambiguous error. A caller holding an + // idempotency lock can compare those events with the persisted receipt. CloseIssueGuarded(ctx context.Context, p CloseIssueParams) (Issue, []Event, bool, error) ReopenIssue(ctx context.Context, issueID int64, actor string) (Issue, *Event, bool, error) SoftDeleteIssue(ctx context.Context, issueID int64, actor string) (Issue, *Event, bool, error) From a4beff1aa53eb4d3e45f6a7fbb3901cfff8d3db9 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 15:36:07 -0500 Subject: [PATCH 07/14] Pin close retries across moves and daemon swaps --- cmd/kata/mcp.go | 26 ++-- internal/daemon/handlers_actions.go | 70 +++++++---- .../daemon/handlers_actions_retry_test.go | 115 ++++++++++++++++++ internal/db/dbtest/conformance_core.go | 19 +++ internal/db/errors.go | 4 + internal/db/params.go | 1 + internal/db/pgstore/idempotency.go | 2 +- internal/db/pgstore/issue_lifecycle.go | 3 + internal/db/sqlitestore/queries.go | 3 + .../db/sqlitestore/queries_idempotency.go | 1 + internal/db/types.go | 1 + internal/mcp/close_retry_test.go | 44 ++++++- internal/mcp/handlers.go | 9 +- internal/mcp/server.go | 20 +-- 14 files changed, 270 insertions(+), 48 deletions(-) diff --git a/cmd/kata/mcp.go b/cmd/kata/mcp.go index c6c93490a..d20be1f33 100644 --- a/cmd/kata/mcp.go +++ b/cmd/kata/mcp.go @@ -98,9 +98,6 @@ func newMCPServeCmd() *cobra.Command { if err != nil { return err } - closeRetryHeadersSupported, _ := apiVersionAtLeast( - health.APISchemaVersion, apiVersionCloseRetrySafety, - ) stopKeepalive := startMCPIdleKeepalive(ctx, httpClient, baseURL, health) defer stopKeepalive() actor, _ := resolveActor(ctx, flags.As, nil) @@ -145,16 +142,19 @@ func newMCPServeCmd() *cobra.Command { return fmt.Errorf("resolve MCP project scope: %w", err) } server, err := mcpserver.New(mcpserver.Options{ - Client: apiClient, - LongRunningClient: longRunningAPIClient, - Scope: scope, - ProjectID: projectID, - ProjectName: projectName, - Actor: actor, - Version: version.Version, - StorageAdmin: storage, - EnableTokenAdmin: enableTokenAdmin, - CloseRetryHeadersSupported: closeRetryHeadersSupported, + Client: apiClient, + LongRunningClient: longRunningAPIClient, + Scope: scope, + ProjectID: projectID, + ProjectName: projectName, + Actor: actor, + Version: version.Version, + StorageAdmin: storage, + EnableTokenAdmin: enableTokenAdmin, + CheckCloseRetrySupport: func(callCtx context.Context) error { + return requireDaemonAPIVersion(callCtx, httpClient, baseURL, + apiVersionCloseRetrySafety, "kata.close retry controls") + }, }) if err != nil { return err diff --git a/internal/daemon/handlers_actions.go b/internal/daemon/handlers_actions.go index bcd4fec62..d893321e0 100644 --- a/internal/daemon/handlers_actions.go +++ b/internal/daemon/handlers_actions.go @@ -58,14 +58,6 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { // so duplicate/superseded closes still must carry their typed // targets and won't corrupt the audit trail. tuiBypass := tuiBypassAllowed(ctx, in.Body.Source, in.Body.Reason) - includeDeleted := db.IncludeDeletedNo - if in.IdempotencyKey != "" { - includeDeleted = db.IncludeDeletedYes - } - issue, err := activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, includeDeleted) - if err != nil { - return nil, err - } idempotencyFingerprint := "" if in.IdempotencyKey != "" { release, err := cfg.DB.AcquireIdempotencyLock(ctx, in.ProjectID, in.IdempotencyKey) @@ -74,18 +66,30 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { } defer func() { _ = release() }() - idempotencyFingerprint = closeIdempotencyFingerprint( - issue.UID, actor, in.Body.Reason, in.Body.Message, in.Body.Source, - in.Body.Evidence, in.Body.DryRun, ifMatchRev) - reuse, err := tryCloseIdempotencyMatch( - ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint) + match, err := lookupCloseIdempotencyMatch(ctx, cfg, in.ProjectID, in.IdempotencyKey) if err != nil { return nil, err } - if reuse != nil { - return reuse, nil + if match != nil { + idempotencyFingerprint = closeIdempotencyFingerprint( + match.IssueUID, in.Ref, actor, in.Body.Reason, in.Body.Message, in.Body.Source, + in.Body.Evidence, in.Body.DryRun, ifMatchRev) + return closeIdempotencyResponse(ctx, cfg, match, idempotencyFingerprint) } } + includeDeleted := db.IncludeDeletedNo + if in.IdempotencyKey != "" { + includeDeleted = db.IncludeDeletedYes + } + issue, err := activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, includeDeleted) + if err != nil { + return nil, err + } + if in.IdempotencyKey != "" { + idempotencyFingerprint = closeIdempotencyFingerprint( + issue.UID, in.Ref, actor, in.Body.Reason, in.Body.Message, in.Body.Source, + in.Body.Evidence, in.Body.DryRun, ifMatchRev) + } if issue.DeletedAt != nil { return nil, api.NewError(404, "issue_not_found", "issue not found", "", nil) } @@ -181,7 +185,8 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { evt = nil events = nil updated, events, changed, err = cfg.DB.CloseIssueGuarded(ctx, db.CloseIssueParams{ - IssueID: issue.ID, Reason: in.Body.Reason, Actor: actor, + IssueID: issue.ID, ExpectedProjectID: in.ProjectID, + Reason: in.Body.Reason, Actor: actor, Message: in.Body.Message, Evidence: dbEvidence, IfMatchRev: ifMatchRev, IdempotencyKey: in.IdempotencyKey, IdempotencyFingerprint: idempotencyFingerprint, }) @@ -222,6 +227,11 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { } return nil, api.NewError(409, "parent_has_open_children", detail, "", nil) } + if errors.Is(err, db.ErrIssueProjectChanged) { + return nil, api.NewError(409, "issue_moved", + "issue moved to another project before the close committed", + "resolve the issue in its current project and retry with a fresh idempotency key", nil) + } if errors.Is(err, db.ErrFederatedReadOnly) { return nil, federationReadOnlyError(err) } @@ -310,14 +320,30 @@ func tryCloseIdempotencyMatch( projectID int64, key, fingerprint string, ) (*api.MutationResponse, error) { - match, err := cfg.DB.LookupIssueMutationIdempotency( - ctx, projectID, "issue.closed", key, time.Now().Add(-idempotencyWindow)) + match, err := lookupCloseIdempotencyMatch(ctx, cfg, projectID, key) if err != nil { - return nil, internalAPIError(err) + return nil, err } if match == nil { return nil, nil } + return closeIdempotencyResponse(ctx, cfg, match, fingerprint) +} + +func lookupCloseIdempotencyMatch( + ctx context.Context, cfg ServerConfig, projectID int64, key string, +) (*db.IdempotencyMatch, error) { + match, err := cfg.DB.LookupIssueMutationIdempotency( + ctx, projectID, "issue.closed", key, time.Now().Add(-idempotencyWindow)) + if err != nil { + return nil, internalAPIError(err) + } + return match, nil +} + +func closeIdempotencyResponse( + ctx context.Context, cfg ServerConfig, match *db.IdempotencyMatch, fingerprint string, +) (*api.MutationResponse, error) { if match.Fingerprint != fingerprint { return nil, api.NewError(409, "idempotency_mismatch", "idempotency key matched a prior close with a different fingerprint", @@ -336,13 +362,14 @@ func tryCloseIdempotencyMatch( } func closeIdempotencyFingerprint( - issueUID, actor, reason, message, source string, + issueUID, requestRef, actor, reason, message, source string, evidence []api.Evidence, dryRun bool, ifMatchRev *int64, ) string { encoded, _ := json.Marshal(struct { IssueUID string `json:"issue_uid"` + RequestRef string `json:"request_ref"` Actor string `json:"actor"` Reason string `json:"reason"` Message string `json:"message"` @@ -351,7 +378,8 @@ func closeIdempotencyFingerprint( DryRun bool `json:"dry_run"` IfMatchRev *int64 `json:"if_match_revision"` }{ - IssueUID: issueUID, Actor: actor, Reason: reason, Message: message, + IssueUID: issueUID, RequestRef: requestRef, + Actor: actor, Reason: reason, Message: message, Source: source, Evidence: evidence, DryRun: dryRun, IfMatchRev: ifMatchRev, }) sum := sha256.Sum256(encoded) diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go index dd1d4840c..9c9a5d779 100644 --- a/internal/daemon/handlers_actions_retry_test.go +++ b/internal/daemon/handlers_actions_retry_test.go @@ -21,6 +21,32 @@ type closeRaceStore struct { raceNext bool } +type closeMoveRaceStore struct { + db.Storage + toProjectID int64 + moveNext bool +} + +func (s *closeMoveRaceStore) CloseIssueGuarded( + ctx context.Context, params db.CloseIssueParams, +) (db.Issue, []db.Event, bool, error) { + if s.moveNext { + s.moveNext = false + issue, err := s.IssueByID(ctx, params.IssueID) + if err != nil { + return db.Issue{}, nil, false, err + } + _, err = s.MoveIssueProject(ctx, db.MoveIssueProjectIn{ + IssueID: issue.ID, FromProjectID: issue.ProjectID, ToProjectID: s.toProjectID, + IfMatchRev: issue.Revision, Actor: "moving-agent", + }) + if err != nil { + return db.Issue{}, nil, false, err + } + } + return s.Storage.CloseIssueGuarded(ctx, params) +} + func (s *closeRaceStore) CloseIssueGuarded( ctx context.Context, params db.CloseIssueParams, ) (db.Issue, []db.Event, bool, error) { @@ -137,6 +163,43 @@ func TestClose_IdempotencyReplaysReceiptAfterSoftDelete(t *testing.T) { require.NotNil(t, retryOut.Body.Issue.DeletedAt) } +func TestClose_IdempotencyReplaysReceiptAfterMove(t *testing.T) { + h, ts, projectID, issueID := bootstrapProjectWithIssue(t) + issue, err := h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + path := issueURLRef(projectID, issue.ShortID, "actions/close") + headers := map[string]string{"Idempotency-Key": "close-request-then-move"} + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + } + + first := postWithHeader(t, ts, path, headers, body) + requireOK(t, first) + var firstOut api.MutationResponse + require.NoError(t, json.Unmarshal(first.body, &firstOut.Body)) + require.NotNil(t, firstOut.Body.Event) + + target, err := h.DB().CreateProject(t.Context(), "target") + require.NoError(t, err) + moved, err := h.DB().MoveIssueProject(t.Context(), db.MoveIssueProjectIn{ + IssueID: issueID, FromProjectID: projectID, ToProjectID: target.ID, + IfMatchRev: issue.Revision, Actor: "coordinator", + }) + require.NoError(t, err) + + retry := postWithHeader(t, ts, path, headers, body) + requireOK(t, retry) + var retryOut api.MutationResponse + require.NoError(t, json.Unmarshal(retry.body, &retryOut.Body)) + assert.False(t, retryOut.Body.Changed) + assert.True(t, retryOut.Body.Reused) + require.NotNil(t, retryOut.Body.OriginalEvent) + assert.Equal(t, firstOut.Body.Event.UID, retryOut.Body.OriginalEvent.UID) + assert.Equal(t, moved.Issue.ProjectID, retryOut.Body.Issue.ProjectID) +} + func TestClose_RejectsKeyWhenAnotherCloseWinsBeforeWrite(t *testing.T) { database := openTestDB(t) project, err := database.db.CreateProject(t.Context(), "kata") @@ -170,6 +233,33 @@ func TestClose_RejectsKeyWhenAnotherCloseWinsBeforeWrite(t *testing.T) { assert.False(t, retryOut.Body.Reused) } +func TestClose_RejectsMoveThatWinsBeforeGuardedWrite(t *testing.T) { + database := openTestDB(t) + source, err := database.db.CreateProject(t.Context(), "source") + require.NoError(t, err) + target, err := database.db.CreateProject(t.Context(), "target") + require.NoError(t, err) + issue, _, err := database.db.CreateIssue(t.Context(), db.CreateIssueParams{ + ProjectID: source.ID, Title: "move race", Author: "agent-one", + }) + require.NoError(t, err) + store := &closeMoveRaceStore{Storage: database.db, toProjectID: target.ID, moveNext: true} + ts := startTestServer(t, daemon.ServerConfig{DB: store, StartedAt: database.now}) + response := postWithHeader(t, ts, + issueURLRef(source.ID, issue.ShortID, "actions/close"), + map[string]string{"Idempotency-Key": "close-move-race-1"}, map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + }) + assertAPIError(t, response.status, response.body, http.StatusConflict, "issue_moved") + + current, err := database.db.IssueByID(t.Context(), issue.ID) + require.NoError(t, err) + assert.Equal(t, target.ID, current.ProjectID) + assert.Equal(t, "open", current.Status) +} + func TestClose_RecoversCommittedReceiptAndPublishesEventsOnce(t *testing.T) { database := openTestDB(t) project, issue := createClaimHubIssueInDB(t, database.db) @@ -233,6 +323,31 @@ func TestClose_IdempotencyRejectsDifferentRequest(t *testing.T) { assertAPIError(t, retry.status, retry.body, http.StatusConflict, "idempotency_mismatch") } +func TestClose_IdempotencyRejectsDifferentIssueWithSameBody(t *testing.T) { + h, ts, projectID, firstID := bootstrapProjectWithIssue(t) + first, err := h.DB().IssueByID(t.Context(), firstID) + require.NoError(t, err) + second, _, err := h.DB().CreateIssue(t.Context(), db.CreateIssueParams{ + ProjectID: projectID, Title: "second issue", Author: "agent-one", + }) + require.NoError(t, err) + headers := map[string]string{"Idempotency-Key": "close-request-1"} + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + } + requireOK(t, postWithHeader(t, ts, + issueURLRef(projectID, first.ShortID, "actions/close"), headers, body)) + + retry := postWithHeader(t, ts, + issueURLRef(projectID, second.ShortID, "actions/close"), headers, body) + assertAPIError(t, retry.status, retry.body, http.StatusConflict, "idempotency_mismatch") + current, err := h.DB().IssueByID(t.Context(), second.ID) + require.NoError(t, err) + assert.Equal(t, "open", current.Status) +} + func TestClose_IfMatchRejectsStaleRevision(t *testing.T) { h, ts, projectID, issueID := bootstrapProjectWithIssue(t) _, err := h.DB().PatchIssueMetadata(context.Background(), db.PatchIssueMetadataIn{ diff --git a/internal/db/dbtest/conformance_core.go b/internal/db/dbtest/conformance_core.go index 70f97d841..555414a8d 100644 --- a/internal/db/dbtest/conformance_core.go +++ b/internal/db/dbtest/conformance_core.go @@ -364,6 +364,25 @@ func checkIdempotency(t *testing.T, store db.Storage) error { assert.Equal(t, closeEvents[0].UID, closeMatch.Event.UID) assert.Equal(t, "close-fingerprint-1", closeMatch.Fingerprint) + projectGuardIssue, _, err := store.CreateIssue(ctx, db.CreateIssueParams{ + ProjectID: project.ID, Title: "project-pinned close", Author: "conformance-agent", + }) + if err != nil { + return fmt.Errorf("create project-pinned close issue: %w", err) + } + _, _, _, err = store.CloseIssueGuarded(ctx, db.CloseIssueParams{ + IssueID: projectGuardIssue.ID, ExpectedProjectID: project.ID + 9999, + Reason: "wontfix", Actor: "conformance-agent", + }) + if !errors.Is(err, db.ErrIssueProjectChanged) { + return fmt.Errorf("project-pinned close returned %v, want issue project changed", err) + } + projectGuardIssue, err = store.IssueByID(ctx, projectGuardIssue.ID) + if err != nil { + return fmt.Errorf("read project-pinned close issue: %w", err) + } + assert.Equal(t, "open", projectGuardIssue.Status) + staleIssue, _, err := store.CreateIssue(ctx, db.CreateIssueParams{ ProjectID: project.ID, Title: "stale close guard", Author: "conformance-agent", }) diff --git a/internal/db/errors.go b/internal/db/errors.go index ad7e0c9d2..590578e0d 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -19,6 +19,10 @@ var ( // has open child issues. ErrOpenChildren = errors.New("issue has open children") + // ErrIssueProjectChanged is returned when a guarded mutation finds that + // its issue moved after the caller resolved the project-scoped route. + ErrIssueProjectChanged = errors.New("issue project changed") + // ErrNoFields is returned by EditIssue when no field changes are // requested. ErrNoFields = errors.New("no fields to update") diff --git a/internal/db/params.go b/internal/db/params.go index f60a09a59..04f2ea63a 100644 --- a/internal/db/params.go +++ b/internal/db/params.go @@ -88,6 +88,7 @@ type CreateIssueParams struct { // receipt after losing the response. type CloseIssueParams struct { IssueID int64 + ExpectedProjectID int64 Reason string Actor string Message string diff --git a/internal/db/pgstore/idempotency.go b/internal/db/pgstore/idempotency.go index 61e98fb54..d2f504c7e 100644 --- a/internal/db/pgstore/idempotency.go +++ b/internal/db/pgstore/idempotency.go @@ -56,7 +56,7 @@ func (s *Store) LookupIssueMutationIdempotency( return nil, mapSQLError(err, nil) } return &db.IdempotencyMatch{ - IssueID: issue.ID, IssueShortID: issue.ShortID, + IssueID: issue.ID, IssueUID: issue.UID, IssueShortID: issue.ShortID, Fingerprint: fingerprint.String, Event: event, }, nil } diff --git a/internal/db/pgstore/issue_lifecycle.go b/internal/db/pgstore/issue_lifecycle.go index 7e92c2fc5..07373e91b 100644 --- a/internal/db/pgstore/issue_lifecycle.go +++ b/internal/db/pgstore/issue_lifecycle.go @@ -278,6 +278,9 @@ func (s *Store) closeIssueWithEvents( if err != nil { return err } + if p.ExpectedProjectID != 0 && current.ProjectID != p.ExpectedProjectID { + return db.ErrIssueProjectChanged + } if p.IfMatchRev != nil && current.Revision != *p.IfMatchRev { return &db.RevisionConflictError{CurrentRevision: current.Revision} } diff --git a/internal/db/sqlitestore/queries.go b/internal/db/sqlitestore/queries.go index 0931695da..0347e684f 100644 --- a/internal/db/sqlitestore/queries.go +++ b/internal/db/sqlitestore/queries.go @@ -1484,6 +1484,9 @@ func (d *Store) closeIssueGuarded( if err != nil { return db.Issue{}, nil, false, err } + if p.ExpectedProjectID != 0 && issue.ProjectID != p.ExpectedProjectID { + return db.Issue{}, nil, false, db.ErrIssueProjectChanged + } if p.IfMatchRev != nil && issue.Revision != *p.IfMatchRev { return db.Issue{}, nil, false, &db.RevisionConflictError{CurrentRevision: issue.Revision} } diff --git a/internal/db/sqlitestore/queries_idempotency.go b/internal/db/sqlitestore/queries_idempotency.go index cb8303bae..f364caede 100644 --- a/internal/db/sqlitestore/queries_idempotency.go +++ b/internal/db/sqlitestore/queries_idempotency.go @@ -179,6 +179,7 @@ func (d *Store) LookupIssueMutationIdempotency( } return &db.IdempotencyMatch{ IssueID: issue.ID, + IssueUID: issue.UID, IssueShortID: issue.ShortID, Fingerprint: fp.String, Event: evt, diff --git a/internal/db/types.go b/internal/db/types.go index d9b13eb23..000c81867 100644 --- a/internal/db/types.go +++ b/internal/db/types.go @@ -559,6 +559,7 @@ type SearchCandidate struct { // receipt without a second query. type IdempotencyMatch struct { IssueID int64 + IssueUID string IssueShortID string Fingerprint string Event Event diff --git a/internal/mcp/close_retry_test.go b/internal/mcp/close_retry_test.go index 2eabf8899..40e37a2b8 100644 --- a/internal/mcp/close_retry_test.go +++ b/internal/mcp/close_retry_test.go @@ -1,6 +1,8 @@ package mcpserver import ( + "context" + "errors" "net/http" "strings" "testing" @@ -34,7 +36,7 @@ func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { }) handlers := toolHandlers{options: Options{ Client: client, Scope: NewAllScope(), Actor: "example-agent", - CloseRetryHeadersSupported: true, + CheckCloseRetrySupport: func(context.Context) error { return nil }, }} revision := int64(7) _, output, err := handlers.close(t.Context(), nil, CloseInput{ @@ -51,6 +53,46 @@ func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { require.Equal(t, "01HCCCCCCCCCCCCCCCCCCCCCCC", output.Event.UID) } +func TestCloseRechecksRetrySupportEachCall(t *testing.T) { + var checks, closeCalls int + client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == "/api/v1/projects": + writeJSON(writer, map[string]any{"projects": []any{ + projectJSON(1, "01HAAAAAAAAAAAAAAAAAAAAAAA", "spoke-project"), + }}) + case strings.HasSuffix(request.URL.Path, "/actions/close"): + closeCalls++ + writeJSON(writer, map[string]any{ + "issue": issueJSON(1, "spoke-project", "abc1"), "changed": true, + }) + default: + http.NotFound(writer, request) + } + }) + handlers := toolHandlers{options: Options{ + Client: client, Scope: NewAllScope(), Actor: "example-agent", + CheckCloseRetrySupport: func(context.Context) error { + checks++ + if checks > 1 { + return errors.New("daemon API became incompatible") + } + return nil + }, + }} + input := CloseInput{ + Ref: "spoke-project#abc1", Reason: "wontfix", + Message: "Reviewed the request and recorded why the work should stop here.", + IdempotencyKey: "close-request-1", + } + _, _, err := handlers.close(t.Context(), nil, input) + require.NoError(t, err) + _, _, err = handlers.close(t.Context(), nil, input) + require.ErrorContains(t, err, "daemon API became incompatible") + require.Equal(t, 2, checks) + require.Equal(t, 1, closeCalls) +} + func TestCloseRejectsRetryGuardsWhenDaemonDoesNotSupportThem(t *testing.T) { var closeCalls int client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index e6ec4f626..19ab3b78d 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -753,8 +753,13 @@ func (h toolHandlers) patchMetadata(ctx context.Context, project ProjectIdentity } func (h toolHandlers) close(ctx context.Context, _ *sdkmcp.CallToolRequest, input CloseInput) (*sdkmcp.CallToolResult, MutationOutput, error) { - if (input.IdempotencyKey != "" || input.Revision != nil) && !h.options.CloseRetryHeadersSupported { - return nil, MutationOutput{}, errors.New("daemon does not support kata.close retry controls; upgrade the daemon") + if input.IdempotencyKey != "" || input.Revision != nil { + if h.options.CheckCloseRetrySupport == nil { + return nil, MutationOutput{}, errors.New("daemon does not support kata.close retry controls; upgrade the daemon") + } + if err := h.options.CheckCloseRetrySupport(ctx); err != nil { + return nil, MutationOutput{}, err + } } project, ref, err := h.options.Scope.IssueTarget(ctx, h.options.Client, input.Ref, true) if err != nil { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 0dba55a11..d7468d5a4 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -27,16 +27,16 @@ const ( // Options fixes the Kata daemon scope and actor for one MCP process. type Options struct { - Client *kataclient.Client - LongRunningClient *kataclient.Client - Scope *Scope - ProjectID int64 - ProjectName string - Actor string - Version string - StorageAdmin *storageadmin.Admin - EnableTokenAdmin bool - CloseRetryHeadersSupported bool + Client *kataclient.Client + LongRunningClient *kataclient.Client + Scope *Scope + ProjectID int64 + ProjectName string + Actor string + Version string + StorageAdmin *storageadmin.Admin + EnableTokenAdmin bool + CheckCloseRetrySupport func(context.Context) error } // New creates a tools-only MCP server for one startup project scope. From 2b5c0e7904e0b61292705de4829325cc6496494a Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 16:03:12 -0500 Subject: [PATCH 08/14] Keep close retries scoped after issue moves --- cmd/kata/close.go | 15 +++- cmd/kata/close_reopen_test.go | 30 +++++++ internal/daemon/handlers_comments.go | 31 +++++-- internal/daemon/handlers_comments_test.go | 31 +++++++ internal/db/dbtest/conformance_core.go | 12 ++- internal/db/pgstore/idempotency.go | 19 ++++- .../db/sqlitestore/queries_idempotency.go | 32 +++++-- internal/db/storage.go | 5 +- internal/db/types.go | 1 + internal/mcp/close_retry_test.go | 85 +++++++++++++++++++ internal/mcp/handlers.go | 32 +++++++ 11 files changed, 272 insertions(+), 21 deletions(-) diff --git a/cmd/kata/close.go b/cmd/kata/close.go index c51be0e02..38f256408 100644 --- a/cmd/kata/close.go +++ b/cmd/kata/close.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "maps" "net/http" @@ -280,8 +281,20 @@ func runActionWithHeaders( if key := headers["Idempotency-Key"]; key != "" { commentKey = "close-comment:" + key } + commentProjectID, commentIssueRef := pid, issue.RefForAPI + if comment != "" && commentKey != "" { + var response api.MutationResponse + if err := json.Unmarshal(bs, &response.Body); err != nil { + return fmt.Errorf("decode close response for follow-up comment: %w", err) + } + if response.Body.Issue.ProjectID <= 0 || response.Body.Issue.UID == "" { + return fmt.Errorf("close response is missing the issue identity for the follow-up comment") + } + commentProjectID = response.Body.Issue.ProjectID + commentIssueRef = response.Body.Issue.UID + } if err := postFollowupCommentWithKey( - ctx, client, baseURL, pid, issue.RefForAPI, actor, comment, commentKey, + ctx, client, baseURL, commentProjectID, commentIssueRef, actor, comment, commentKey, ); err != nil { return err } diff --git a/cmd/kata/close_reopen_test.go b/cmd/kata/close_reopen_test.go index 15ce63f95..23cb4c213 100644 --- a/cmd/kata/close_reopen_test.go +++ b/cmd/kata/close_reopen_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/kata/internal/db" ) func TestCloseReopen_RoundTrip(t *testing.T) { @@ -467,6 +468,35 @@ func TestClose_RetryWithCommentAppendsOneComment(t *testing.T) { assert.Equal(t, "fixed in abc1234", got.Comments[0].Body) } +func TestClose_RetryWithCommentAfterMoveAppendsOneComment(t *testing.T) { + env, dir, sourceProjectID, ref := setupWorkspaceWithIssue(t, "test issue") + args := []string{ + "close", ref, + "--done", + "--message", "Implemented the requested behavior and ran the focused tests.", + "--test", "go test ./cmd/kata", + "--comment", "fixed in abc1234", + "--idempotency-key", "close-request-with-comment-then-move", + } + runCLI(t, env, dir, args...) + issue, err := env.DB.IssueByShortID(t.Context(), sourceProjectID, ref, db.IncludeDeletedNo) + require.NoError(t, err) + target, err := env.DB.CreateProject(t.Context(), "target-project") + require.NoError(t, err) + _, err = env.DB.MoveIssueProject(t.Context(), db.MoveIssueProjectIn{ + IssueID: issue.ID, FromProjectID: sourceProjectID, ToProjectID: target.ID, + IfMatchRev: issue.Revision, Actor: "coordinator", + }) + require.NoError(t, err) + + runCLI(t, env, dir, args...) + + comments, err := env.DB.CommentsByIssue(t.Context(), issue.ID) + require.NoError(t, err) + require.Len(t, comments, 1) + assert.Equal(t, "fixed in abc1234", comments[0].Body) +} + func TestReopen_WithComment_AppendsComment(t *testing.T) { env, dir, pid, ref := setupWorkspaceWithIssue(t, "test issue") runCLI(t, env, dir, "close", ref, diff --git a/internal/daemon/handlers_comments.go b/internal/daemon/handlers_comments.go index 96dc9b0a5..082c26bf6 100644 --- a/internal/daemon/handlers_comments.go +++ b/internal/daemon/handlers_comments.go @@ -13,6 +13,7 @@ import ( "go.kenn.io/kata/internal/api" "go.kenn.io/kata/internal/db" + "go.kenn.io/kata/internal/uid" ) // registerCommentsHandlers installs POST /comments. CreateComment writes the @@ -28,31 +29,40 @@ func registerCommentsHandlers(humaAPI huma.API, cfg ServerConfig) { if err != nil { return nil, err } - issue, err := activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, db.IncludeDeletedNo) - if err != nil { - return nil, err + lookupIssueUID := "" + if uid.Valid(in.Ref) { + lookupIssueUID = strings.ToUpper(in.Ref) } fingerprint := "" if in.IdempotencyKey != "" { - release, err := cfg.DB.AcquireIdempotencyLock(ctx, in.ProjectID, in.IdempotencyKey) + lockProjectID, lockKey := in.ProjectID, in.IdempotencyKey + if lookupIssueUID != "" { + // Project IDs change when an issue moves. Zero is outside the + // persisted project ID range and gives UID-addressed retries one + // stable, backend-wide lock scope. + lockProjectID = 0 + lockKey = lookupIssueUID + "\x00" + in.IdempotencyKey + } + release, err := cfg.DB.AcquireIdempotencyLock(ctx, lockProjectID, lockKey) if err != nil { return nil, internalAPIError(err) } defer func() { _ = release() }() - fingerprint = commentIdempotencyFingerprint(issue.UID, actor, in.Body.Body) match, err := cfg.DB.LookupCommentIdempotency( - ctx, in.ProjectID, in.IdempotencyKey, time.Now().Add(-idempotencyWindow)) + ctx, in.ProjectID, lookupIssueUID, in.IdempotencyKey, + time.Now().Add(-idempotencyWindow)) if err != nil { return nil, internalAPIError(err) } if match != nil { + fingerprint = commentIdempotencyFingerprint(match.IssueUID, actor, in.Body.Body) if match.Fingerprint != fingerprint { return nil, api.NewError(409, "idempotency_mismatch", "idempotency key matched a prior comment with a different fingerprint", "use a fresh key or send the exact original comment", nil) } - updated, err := cfg.DB.IssueByID(ctx, issue.ID) + updated, err := cfg.DB.IssueByID(ctx, match.Comment.IssueID) if err != nil { return nil, internalAPIError(err) } @@ -64,6 +74,13 @@ func registerCommentsHandlers(humaAPI huma.API, cfg ServerConfig) { return out, nil } } + issue, err := activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, db.IncludeDeletedNo) + if err != nil { + return nil, err + } + if in.IdempotencyKey != "" { + fingerprint = commentIdempotencyFingerprint(issue.UID, actor, in.Body.Body) + } c, evt, err := cfg.DB.CreateComment(ctx, db.CreateCommentParams{ IssueID: issue.ID, Author: actor, diff --git a/internal/daemon/handlers_comments_test.go b/internal/daemon/handlers_comments_test.go index 86828724e..19a804d99 100644 --- a/internal/daemon/handlers_comments_test.go +++ b/internal/daemon/handlers_comments_test.go @@ -56,6 +56,37 @@ func TestCommentEndpoint_IdempotencyReusesCommittedComment(t *testing.T) { require.Len(t, comments, 1) } +func TestCommentEndpoint_IdempotencyReplaysCommittedCommentAfterMove(t *testing.T) { + h, ts, sourceProjectID, issueID := bootstrapProjectWithIssue(t) + issue, err := h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + body := map[string]any{"actor": "agent", "body": "first comment"} + headers := map[string]string{"Idempotency-Key": "comment-request-then-move"} + + first := postWithHeader(t, ts, issueURLRef(sourceProjectID, issue.UID, "comments"), headers, body) + requireOK(t, first) + target, err := h.DB().CreateProject(t.Context(), "target-project") + require.NoError(t, err) + issue, err = h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + _, err = h.DB().MoveIssueProject(t.Context(), db.MoveIssueProjectIn{ + IssueID: issue.ID, FromProjectID: sourceProjectID, ToProjectID: target.ID, + IfMatchRev: issue.Revision, Actor: "coordinator", + }) + require.NoError(t, err) + + retry := postWithHeader(t, ts, issueURLRef(target.ID, issue.UID, "comments"), headers, body) + requireOK(t, retry) + var reused struct { + Changed bool `json:"changed"` + } + require.NoError(t, json.Unmarshal(retry.body, &reused)) + assert.False(t, reused.Changed) + comments, err := h.DB().CommentsByIssue(t.Context(), issueID) + require.NoError(t, err) + require.Len(t, comments, 1) +} + func TestCommentEndpoint_IdempotencyRejectsDifferentBody(t *testing.T) { _, ts, pid, issueID := bootstrapProjectWithIssue(t) path := issueURL(pid, issueID, "comments") diff --git a/internal/db/dbtest/conformance_core.go b/internal/db/dbtest/conformance_core.go index 555414a8d..068631e67 100644 --- a/internal/db/dbtest/conformance_core.go +++ b/internal/db/dbtest/conformance_core.go @@ -328,15 +328,23 @@ func checkIdempotency(t *testing.T, store db.Storage) error { if err != nil { return fmt.Errorf("create comment: %w", err) } - commentMatch, err := store.LookupCommentIdempotency(ctx, project.ID, "comment-request-1", since) + commentMatch, err := store.LookupCommentIdempotency(ctx, project.ID, "", "comment-request-1", since) if err != nil { return fmt.Errorf("lookup comment idempotency: %w", err) } require.NotNil(t, commentMatch) assert.Equal(t, comment.UID, commentMatch.Comment.UID) + assert.Equal(t, issue.UID, commentMatch.IssueUID) assert.Equal(t, commentEvent.UID, commentMatch.Event.UID) assert.Equal(t, "comment-fingerprint-1", commentMatch.Fingerprint) - missingComment, err := store.LookupCommentIdempotency(ctx, project.ID, "comment-request-2", since) + commentMatchByUID, err := store.LookupCommentIdempotency( + ctx, 0, issue.UID, "comment-request-1", since) + if err != nil { + return fmt.Errorf("lookup comment idempotency by issue UID: %w", err) + } + require.NotNil(t, commentMatchByUID) + assert.Equal(t, comment.UID, commentMatchByUID.Comment.UID) + missingComment, err := store.LookupCommentIdempotency(ctx, project.ID, "", "comment-request-2", since) if err != nil { return fmt.Errorf("lookup missing comment idempotency key: %w", err) } diff --git a/internal/db/pgstore/idempotency.go b/internal/db/pgstore/idempotency.go index d2f504c7e..beccbfe89 100644 --- a/internal/db/pgstore/idempotency.go +++ b/internal/db/pgstore/idempotency.go @@ -66,6 +66,7 @@ func (s *Store) LookupIssueMutationIdempotency( func (s *Store) LookupCommentIdempotency( ctx context.Context, projectID int64, + issueUID string, key string, since time.Time, ) (*db.CommentIdempotencyMatch, error) { @@ -74,15 +75,24 @@ func (s *Store) LookupCommentIdempotency( AND e.payload::jsonb ->> 'idempotency_key' = $2 AND e.created_at >= $3 ORDER BY e.id DESC LIMIT 1` - event, err := scanEvent(s.QueryRowContext(ctx, query, projectID, key, formatStoredTime(since))) + scope := any(projectID) + if issueUID != "" { + query = eventSelect + ` WHERE e.type = 'issue.commented' + AND e.issue_uid = $1 + AND e.payload::jsonb ->> 'idempotency_key' = $2 + AND e.created_at >= $3 + ORDER BY e.id DESC LIMIT 1` + scope = issueUID + } + event, err := scanEvent(s.QueryRowContext(ctx, query, scope, key, formatStoredTime(since))) if errors.Is(err, db.ErrNotFound) { return nil, nil } if err != nil { return nil, err } - if event.IssueID == nil { - return nil, fmt.Errorf("comment idempotency match has no issue_id") + if event.IssueID == nil || event.IssueUID == nil { + return nil, fmt.Errorf("comment idempotency match has no issue identity") } var payload struct { CommentUID string `json:"comment_uid"` @@ -97,6 +107,7 @@ func (s *Store) LookupCommentIdempotency( return nil, fmt.Errorf("comment idempotency match comment: %w", err) } return &db.CommentIdempotencyMatch{ - Comment: comment, Fingerprint: payload.Fingerprint, Event: event, + Comment: comment, IssueUID: *event.IssueUID, + Fingerprint: payload.Fingerprint, Event: event, }, nil } diff --git a/internal/db/sqlitestore/queries_idempotency.go b/internal/db/sqlitestore/queries_idempotency.go index f364caede..704f44ff6 100644 --- a/internal/db/sqlitestore/queries_idempotency.go +++ b/internal/db/sqlitestore/queries_idempotency.go @@ -188,8 +188,10 @@ func (d *Store) LookupIssueMutationIdempotency( // LookupCommentIdempotency finds the newest recent issue.commented event // carrying key and returns the comment that event created. -func (d *Store) LookupCommentIdempotency(ctx context.Context, projectID int64, key string, since time.Time) (*db.CommentIdempotencyMatch, error) { - const q = ` +func (d *Store) LookupCommentIdempotency( + ctx context.Context, projectID int64, issueUID, key string, since time.Time, +) (*db.CommentIdempotencyMatch, error) { + q := ` SELECT e.id, e.uid, e.origin_instance_uid, e.project_id, p.uid, e.project_name, e.issue_id, e.issue_uid, e.related_issue_id, e.related_issue_uid, e.type, e.actor, e.payload, @@ -202,7 +204,24 @@ func (d *Store) LookupCommentIdempotency(ctx context.Context, projectID int64, k AND e.created_at >= ? ORDER BY e.id DESC LIMIT 1` - row := d.QueryRowContext(ctx, q, projectID, key, since.UTC().Format(sqliteTimeFormat)) + scope := any(projectID) + if issueUID != "" { + q = ` + SELECT e.id, e.uid, e.origin_instance_uid, e.project_id, p.uid, e.project_name, + e.issue_id, e.issue_uid, + e.related_issue_id, e.related_issue_uid, e.type, e.actor, e.payload, + e.hlc_physical_ms, e.hlc_counter, e.content_hash, e.created_at + FROM events e + JOIN projects p ON p.id = e.project_id + WHERE e.type = 'issue.commented' + AND e.issue_uid = ? + AND json_extract(e.payload, '$.idempotency_key') = ? + AND e.created_at >= ? + ORDER BY e.id DESC + LIMIT 1` + scope = issueUID + } + row := d.QueryRowContext(ctx, q, scope, key, since.UTC().Format(sqliteTimeFormat)) var evt db.Event err := row.Scan(&evt.ID, &evt.UID, &evt.OriginInstanceUID, &evt.ProjectID, &evt.ProjectUID, &evt.ProjectName, @@ -214,8 +233,8 @@ func (d *Store) LookupCommentIdempotency(ctx context.Context, projectID int64, k if err != nil { return nil, fmt.Errorf("lookup comment idempotency: %w", err) } - if evt.IssueID == nil { - return nil, fmt.Errorf("comment idempotency match has no issue_id") + if evt.IssueID == nil || evt.IssueUID == nil { + return nil, fmt.Errorf("comment idempotency match has no issue identity") } var payload struct { CommentUID string `json:"comment_uid"` @@ -236,6 +255,7 @@ func (d *Store) LookupCommentIdempotency(ctx context.Context, projectID int64, k return nil, fmt.Errorf("comment idempotency match comment: %w", err) } return &db.CommentIdempotencyMatch{ - Comment: comment, Fingerprint: payload.Fingerprint, Event: evt, + Comment: comment, IssueUID: *evt.IssueUID, + Fingerprint: payload.Fingerprint, Event: evt, }, nil } diff --git a/internal/db/storage.go b/internal/db/storage.go index 55422fd21..239b2859a 100644 --- a/internal/db/storage.go +++ b/internal/db/storage.go @@ -150,7 +150,10 @@ type Storage interface { AcquireIdempotencyLock(ctx context.Context, projectID int64, key string) (release func() error, err error) LookupIdempotency(ctx context.Context, projectID int64, key string, since time.Time) (*IdempotencyMatch, error) LookupIssueMutationIdempotency(ctx context.Context, projectID int64, eventType, key string, since time.Time) (*IdempotencyMatch, error) - LookupCommentIdempotency(ctx context.Context, projectID int64, key string, since time.Time) (*CommentIdempotencyMatch, error) + // LookupCommentIdempotency scopes by issue UID when issueUID is non-empty; + // otherwise it scopes by project. UID scope lets a committed comment receipt + // survive a later project move without making keys global across issues. + LookupCommentIdempotency(ctx context.Context, projectID int64, issueUID, key string, since time.Time) (*CommentIdempotencyMatch, error) InsertCloseThrottledEvent(ctx context.Context, issueID int64, actor string, payload CloseThrottledPayload) (Event, error) RecentSiblingCloses(ctx context.Context, parentIssueID, excludeIssueID int64, actor string, since time.Time) ([]Event, error) RecentSameMessageClose(ctx context.Context, parentIssueID, excludeIssueID int64, actor, normalizedMessage string, since time.Time) (*Event, error) diff --git a/internal/db/types.go b/internal/db/types.go index 000c81867..25008667c 100644 --- a/internal/db/types.go +++ b/internal/db/types.go @@ -569,6 +569,7 @@ type IdempotencyMatch struct { // through its issue.commented event payload. type CommentIdempotencyMatch struct { Comment Comment + IssueUID string Fingerprint string Event Event } diff --git a/internal/mcp/close_retry_test.go b/internal/mcp/close_retry_test.go index 40e37a2b8..af674ba47 100644 --- a/internal/mcp/close_retry_test.go +++ b/internal/mcp/close_retry_test.go @@ -53,6 +53,91 @@ func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { require.Equal(t, "01HCCCCCCCCCCCCCCCCCCCCCCC", output.Event.UID) } +func TestCloseRejectsReusedIssueMovedOutsideFixedScope(t *testing.T) { + const ( + allowedProjectUID = "01HAAAAAAAAAAAAAAAAAAAAAAA" + foreignProjectUID = "01HBBBBBBBBBBBBBBBBBBBBBBB" + ) + client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == "/api/v1/projects": + writeJSON(writer, map[string]any{"projects": []any{ + projectJSON(1, allowedProjectUID, "spoke-project"), + projectJSON(2, foreignProjectUID, "other-project"), + }}) + case strings.HasSuffix(request.URL.Path, "/actions/close"): + issue := issueJSON(2, "other-project", "def1") + issue["project_uid"] = foreignProjectUID + issue["status"] = "closed" + writeJSON(writer, map[string]any{ + "issue": issue, "original_event": closeRetryEventJSON(), + "changed": false, "reused": true, + }) + default: + http.NotFound(writer, request) + } + }) + scope, err := NewAllowlistScope([]ProjectIdentity{{ + ID: 1, UID: allowedProjectUID, Name: "spoke-project", + }}) + require.NoError(t, err) + handlers := toolHandlers{options: Options{ + Client: client, Scope: scope, Actor: "example-agent", + CheckCloseRetrySupport: func(context.Context) error { return nil }, + }} + + _, _, err = handlers.close(t.Context(), nil, CloseInput{ + Ref: "spoke-project#abc1", Reason: "wontfix", + Message: "Reviewed the request and recorded why the work should stop here.", + IdempotencyKey: "close-request-1", + }) + require.ErrorContains(t, err, "outside the MCP startup scope") +} + +func TestCloseUsesCurrentProjectForReusedIssueMovedWithinScope(t *testing.T) { + const ( + sourceProjectUID = "01HAAAAAAAAAAAAAAAAAAAAAAA" + targetProjectUID = "01HBBBBBBBBBBBBBBBBBBBBBBB" + ) + client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == "/api/v1/projects": + writeJSON(writer, map[string]any{"projects": []any{ + projectJSON(1, sourceProjectUID, "source-project"), + projectJSON(2, targetProjectUID, "target-project"), + }}) + case strings.HasSuffix(request.URL.Path, "/actions/close"): + issue := issueJSON(2, "target-project", "def1") + issue["project_uid"] = targetProjectUID + issue["status"] = "closed" + writeJSON(writer, map[string]any{ + "issue": issue, "original_event": closeRetryEventJSON(), + "changed": false, "reused": true, + }) + default: + http.NotFound(writer, request) + } + }) + scope, err := NewAllowlistScope([]ProjectIdentity{ + {ID: 1, UID: sourceProjectUID, Name: "source-project"}, + {ID: 2, UID: targetProjectUID, Name: "target-project"}, + }) + require.NoError(t, err) + handlers := toolHandlers{options: Options{ + Client: client, Scope: scope, Actor: "example-agent", + CheckCloseRetrySupport: func(context.Context) error { return nil }, + }} + + _, output, err := handlers.close(t.Context(), nil, CloseInput{ + Ref: "source-project#abc1", Reason: "wontfix", + Message: "Reviewed the request and recorded why the work should stop here.", + IdempotencyKey: "close-request-1", + }) + require.NoError(t, err) + require.Equal(t, ProjectIdentity{ID: 2, UID: targetProjectUID, Name: "target-project"}, output.Project) + require.Equal(t, "target-project#def1", output.Issue.QualifiedRef) +} + func TestCloseRechecksRetrySupportEachCall(t *testing.T) { var checks, closeCalls int client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 19ab3b78d..fd8f5eb6f 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -812,9 +812,41 @@ func (h toolHandlers) close(ctx context.Context, _ *sdkmcp.CallToolRequest, inpu if response.OriginalEvent != nil { event = response.OriginalEvent } + project, err = h.closeMutationProject(ctx, project, response.Issue) + if err != nil { + return nil, MutationOutput{}, err + } return successResult(), h.mutation(project, response.Issue, response.Changed, response.Reused, event), nil } +// closeMutationProject keeps replayed close receipts inside the immutable MCP +// startup scope. A retry can return an issue that moved after the original +// close, so the request project is only valid while its UID still matches. +func (h toolHandlers) closeMutationProject( + ctx context.Context, requestProject ProjectIdentity, issue generated.Issue, +) (ProjectIdentity, error) { + projectUID := "" + if issue.ProjectUID != nil { + projectUID = *issue.ProjectUID + } + if projectUID == "" { + return ProjectIdentity{}, errors.New("close response did not include the issue project UID") + } + if requestProject.UID == projectUID || requestProject.UID == "" && requestProject.ID == issue.ProjectID { + return requestProject, nil + } + projects, err := h.options.Scope.Projects(ctx, h.options.Client, false) + if err != nil { + return ProjectIdentity{}, err + } + for _, project := range projects { + if project.UID == projectUID { + return project, nil + } + } + return ProjectIdentity{}, errors.New("close response issue moved outside the MCP startup scope") +} + // Close-guard refusals render child, sibling-cohort, and prior-close // identities that can live outside the startup scope (parent links span // projects), so scoped servers replace those messages with scope-safe From cdf9c9e9778a0585aa8f92c37e4a2a6f5f8c8b37 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 16:48:28 -0500 Subject: [PATCH 09/14] Close the remaining retry safety gaps --- api/openapi.yaml | 37 +++- cmd/kata/api_compat.go | 1 - cmd/kata/api_compat_test.go | 25 ++- cmd/kata/close.go | 9 +- cmd/kata/mcp.go | 4 - docs/reference/http-api.md | 7 +- internal/api/types.go | 14 +- internal/daemon/handlers_actions.go | 129 ++++++++++++- .../daemon/handlers_actions_retry_test.go | 169 +++++++++++++++--- internal/daemon/handlers_comments.go | 5 + internal/daemon/handlers_comments_test.go | 53 ++++++ internal/mcp/close_retry_test.go | 76 +++----- internal/mcp/handlers.go | 26 ++- internal/mcp/server.go | 19 +- pkg/client/generated/enums.go | 55 ++++++ pkg/client/generated/payloads.go | 2 +- pkg/client/generated/types.go | 46 +++++ pkg/client/openapi.yaml | 36 +++- web/src/lib/api/schema.d.ts | 14 +- 19 files changed, 593 insertions(+), 134 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 53e7d8124..c3184072a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -298,6 +298,41 @@ components: - issue_uid - at type: object + CloseActionRequestBody: + additionalProperties: false + properties: + actor: + type: string + dry_run: + type: boolean + evidence: + items: + $ref: "#/components/schemas/Evidence" + type: + - array + - "null" + message: + type: string + reason: + enum: + - done + - wontfix + - duplicate + - superseded + - audit-no-change + - "" + type: string + retry_protocol: + enum: + - close-v1 + - "" + type: string + source: + enum: + - tui + - "" + type: string + type: object Comment: additionalProperties: true properties: @@ -6283,7 +6318,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ActionRequestBody" + $ref: "#/components/schemas/CloseActionRequestBody" required: true responses: "200": diff --git a/cmd/kata/api_compat.go b/cmd/kata/api_compat.go index 7d3a42567..2397c0261 100644 --- a/cmd/kata/api_compat.go +++ b/cmd/kata/api_compat.go @@ -12,7 +12,6 @@ import ( const ( apiVersionReadyAndSearchFilters = "0.8.0" apiVersionGlobalListFilters = "0.9.0" - apiVersionCloseRetrySafety = "0.15.0" // apiVersionMCPServer is the oldest daemon the native MCP server can // drive: it pins relationship targets with to_project_uid and // expected_project_uids and pages audit rows by event_id. diff --git a/cmd/kata/api_compat_test.go b/cmd/kata/api_compat_test.go index 4b09fa76f..7a5e57376 100644 --- a/cmd/kata/api_compat_test.go +++ b/cmd/kata/api_compat_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "net/http" "net/http/httptest" "sync/atomic" @@ -86,16 +87,25 @@ func TestFilteredListAllRejectsDaemonBeforeGlobalListFilters(t *testing.T) { assert.Zero(t, listCalls.Load(), "the unfiltered old endpoint must not be queried") } -func TestCloseRetryFlagsRejectOldDaemonBeforeClose(t *testing.T) { - var closeCalls atomic.Int32 +func TestCloseRetryFlagsMakeOldDaemonRejectCloseBeforeMutation(t *testing.T) { + var closeCalls, mutations atomic.Int32 + var retryProtocol string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/projects/resolve": _, _ = w.Write([]byte(`{"project":{"id":1,"name":"example-project"}}`)) - case "/api/v1/health": - _, _ = w.Write([]byte(`{"ok":true,"api_schema_version":"0.14.0"}`)) case "/api/v1/projects/1/issues/abc1/actions/close": closeCalls.Add(1) + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + retryProtocol, _ = body["retry_protocol"].(string) + if retryProtocol != "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"status":400,"error":{"code":"validation","message":"retry_protocol: unexpected property"}}`)) + return + } + mutations.Add(1) _, _ = w.Write([]byte(`{"changed":true}`)) default: http.NotFound(w, r) @@ -110,9 +120,10 @@ func TestCloseRetryFlagsRejectOldDaemonBeforeClose(t *testing.T) { "--message", "Reviewed the request and recorded why the work should stop here.", "--idempotency-key", "close-request-1") require.Error(t, err) - assert.Contains(t, err.Error(), "requires daemon API 0.15.0 or newer") - assert.Contains(t, err.Error(), "reports 0.14.0") - assert.Zero(t, closeCalls.Load(), "an old daemon must not receive retry headers") + assert.Contains(t, err.Error(), "retry_protocol") + assert.Equal(t, "close-v1", retryProtocol) + assert.Equal(t, int32(1), closeCalls.Load()) + assert.Zero(t, mutations.Load(), "the legacy request schema must reject before mutation") } func TestListAllDefaultsToUnlimited(t *testing.T) { diff --git a/cmd/kata/close.go b/cmd/kata/close.go index 38f256408..aea196861 100644 --- a/cmd/kata/close.go +++ b/cmd/kata/close.go @@ -258,16 +258,13 @@ func runActionWithHeaders( actor, _ := resolveActor(ctx, flags.As, nil) body := map[string]any{"actor": actor} maps.Copy(body, extra) + if action == "close" && len(headers) > 0 { + body["retry_protocol"] = api.CloseRetryProtocol + } client, err := httpClientFor(ctx, baseURL) if err != nil { return err } - if action == "close" && len(headers) > 0 { - if err := requireDaemonAPIVersion(ctx, client, baseURL, - apiVersionCloseRetrySafety, "close retry controls"); err != nil { - return err - } - } status, bs, err := httpDoJSONHeaders(ctx, client, http.MethodPost, fmt.Sprintf("%s/api/v1/projects/%d/issues/%s/actions/%s", baseURL, pid, url.PathEscape(issue.RefForAPI), action), body, headers) diff --git a/cmd/kata/mcp.go b/cmd/kata/mcp.go index d20be1f33..588e3fc96 100644 --- a/cmd/kata/mcp.go +++ b/cmd/kata/mcp.go @@ -151,10 +151,6 @@ func newMCPServeCmd() *cobra.Command { Version: version.Version, StorageAdmin: storage, EnableTokenAdmin: enableTokenAdmin, - CheckCloseRetrySupport: func(callCtx context.Context) error { - return requireDaemonAPIVersion(callCtx, httpClient, baseURL, - apiVersionCloseRetrySafety, "kata.close retry controls") - }, }) if err != nil { return err diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 226e3e440..d331c01fd 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -95,6 +95,11 @@ once at startup and requires API `0.11.0`, because its relationship tools always send the pinned-target fields and its close-audit paging relies on `event_id`. +Guarded close requests use a request-local compatibility check instead of a +separate health probe. A request that sends `Idempotency-Key` or `If-Match` +also sends `retry_protocol: "close-v1"`. Current daemons require the marker; +older daemons reject the unknown field before the close can run. + The field is **optional in the schema** even though current daemons always send it. That is deliberate: a version-detection field has to survive version skew, so a client generated from a schema that includes it can still parse the @@ -107,7 +112,7 @@ and decline to render issue detail. | Version | Change | | --- | --- | -| `0.15.0` | Added close idempotency and revision headers plus retry receipt fields. Clients must check this version before sending the new headers because older daemons ignore them. | +| `0.15.0` | Added close idempotency and revision headers, the `close-v1` request marker, and retry receipt fields. | | `0.14.0` | Added `external` close evidence with its required `account` field. | | `0.13.0` | Added optimistic revision guards to issue and project metadata patch requests. | | `0.12.0` | Added the optional `idle_shutdown` health block for effective auto-start idle shutdown state and capability discovery. | diff --git a/internal/api/types.go b/internal/api/types.go index 7ce551126..4b7a39d93 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -8,6 +8,10 @@ import ( "go.kenn.io/kata/internal/db" ) +// CloseRetryProtocol marks a close request whose retry headers must be +// understood by the receiving daemon before it may mutate an issue. +const CloseRetryProtocol = "close-v1" + // PingResponse mirrors the cheapest liveness response. type PingResponse struct { Body struct { @@ -841,7 +845,15 @@ type CloseActionRequest struct { Ref string `path:"ref" required:"true"` IdempotencyKey string `header:"Idempotency-Key"` IfMatch string `header:"If-Match"` - Body ActionRequestBody + Body CloseActionRequestBody +} + +// CloseActionRequestBody extends the legacy action body with a close-only +// protocol marker. Older daemons reject the unknown marker before they can +// ignore retry headers and mutate an issue. +type CloseActionRequestBody struct { + ActionRequestBody + RetryProtocol string `json:"retry_protocol,omitempty" enum:"close-v1,"` } // ActionRequestBody is the shared JSON body for close and reopen actions. diff --git a/internal/daemon/handlers_actions.go b/internal/daemon/handlers_actions.go index d893321e0..6e46bd5b6 100644 --- a/internal/daemon/handlers_actions.go +++ b/internal/daemon/handlers_actions.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "sync" "time" "github.com/danielgtaylor/huma/v2" @@ -20,11 +21,17 @@ import ( // issue is already in the target state; both fields propagate verbatim into // the MutationResponse envelope. func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { + var closeDeliveries pendingCloseDeliveries huma.Register(humaAPI, huma.Operation{ OperationID: "closeIssue", Method: "POST", Path: "/api/v1/projects/{project_id}/issues/{ref}/actions/close", }, func(ctx context.Context, in *api.CloseActionRequest) (*api.MutationResponse, error) { + if (in.IdempotencyKey != "" || in.IfMatch != "") && + in.Body.RetryProtocol != api.CloseRetryProtocol { + return nil, api.NewError(400, "retry_protocol_required", + "retry_protocol close-v1 is required with close retry headers", "", nil) + } actor, err := attributedActor(ctx, in.Body.Actor) if err != nil { return nil, err @@ -65,6 +72,16 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { return nil, internalAPIError(err) } defer func() { _ = release() }() + if pending, ok := closeDeliveries.get(in.ProjectID, in.IdempotencyKey); ok { + retryFingerprint := closeIdempotencyFingerprint( + pending.issueUID, in.Ref, actor, in.Body.Reason, in.Body.Message, in.Body.Source, + in.Body.Evidence, in.Body.DryRun, ifMatchRev) + if err := closeDeliveries.publishCommitted( + ctx, cfg, in.ProjectID, in.IdempotencyKey, retryFingerprint, + ); err != nil { + return nil, err + } + } match, err := lookupCloseIdempotencyMatch(ctx, cfg, in.ProjectID, in.IdempotencyKey) if err != nil { @@ -193,8 +210,23 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { if len(events) > 0 { evt = &events[0] } + if err != nil && in.IdempotencyKey != "" && len(events) > 0 { + closeDeliveries.remember(in.ProjectID, in.IdempotencyKey, + issue.UID, idempotencyFingerprint, events) + } return err }) + if err == nil && changed { + closeDeliveries.discard(in.ProjectID, in.IdempotencyKey, idempotencyFingerprint) + cfg.Publish().Events(in.ProjectID, events) + } + if in.IdempotencyKey != "" && (err != nil || !changed) { + if recoveryErr := closeDeliveries.publishCommitted( + ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint, + ); recoveryErr != nil { + return nil, recoveryErr + } + } if err != nil { // A connection can fail after the database commits. The keyed // receipt proves whether this attempt landed. When it did, publish @@ -205,8 +237,7 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { if lookupErr != nil { return nil, lookupErr } - if reuse != nil && closeEventBatchMatchesReceipt(events, reuse) { - cfg.Publish().Events(in.ProjectID, events) + if reuse != nil { return reuse, nil } } @@ -253,9 +284,6 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { "issue was closed by another request before this close committed", "retry after reopening, or omit the idempotency key to accept the current state", nil) } - if changed { - cfg.Publish().Events(in.ProjectID, events) - } out := &api.MutationResponse{} out.Body.Issue = updated out.Body.Event = evt @@ -309,9 +337,94 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { }) } -func closeEventBatchMatchesReceipt(events []db.Event, response *api.MutationResponse) bool { - return len(events) > 0 && response != nil && response.Body.OriginalEvent != nil && - events[0].UID == response.Body.OriginalEvent.UID +type pendingCloseDeliveryKey struct { + projectID int64 + idempotencyKey string +} + +type pendingCloseDelivery struct { + issueUID string + fingerprint string + events []db.Event +} + +// pendingCloseDeliveries retains the event identities returned by a close +// whose commit response was ambiguous. A later exact retry can verify those +// events in storage and deliver them before it returns the stored receipt. +type pendingCloseDeliveries struct { + mu sync.Mutex + pending map[pendingCloseDeliveryKey]pendingCloseDelivery +} + +func (p *pendingCloseDeliveries) remember( + projectID int64, key, issueUID, fingerprint string, events []db.Event, +) { + p.mu.Lock() + defer p.mu.Unlock() + if p.pending == nil { + p.pending = make(map[pendingCloseDeliveryKey]pendingCloseDelivery) + } + p.pending[pendingCloseDeliveryKey{projectID: projectID, idempotencyKey: key}] = pendingCloseDelivery{ + issueUID: issueUID, fingerprint: fingerprint, events: append([]db.Event(nil), events...), + } +} + +func (p *pendingCloseDeliveries) get( + projectID int64, key string, +) (pendingCloseDelivery, bool) { + p.mu.Lock() + defer p.mu.Unlock() + delivery, ok := p.pending[pendingCloseDeliveryKey{projectID: projectID, idempotencyKey: key}] + return delivery, ok +} + +func (p *pendingCloseDeliveries) discard(projectID int64, key, fingerprint string) { + p.mu.Lock() + defer p.mu.Unlock() + mapKey := pendingCloseDeliveryKey{projectID: projectID, idempotencyKey: key} + if delivery, ok := p.pending[mapKey]; ok && delivery.fingerprint == fingerprint { + delete(p.pending, mapKey) + } +} + +func (p *pendingCloseDeliveries) publishCommitted( + ctx context.Context, cfg ServerConfig, projectID int64, key, fingerprint string, +) error { + delivery, ok := p.get(projectID, key) + if !ok || delivery.fingerprint != fingerprint { + return nil + } + uids := make([]string, len(delivery.events)) + for i := range delivery.events { + uids[i] = delivery.events[i].UID + } + stored, err := cfg.DB.EventsByUIDs(ctx, projectID, uids) + if errors.Is(err, db.ErrNotFound) { + p.discard(projectID, key, fingerprint) + return nil + } + if err != nil { + return internalAPIError(err) + } + if !sameCloseEventBatch(delivery.events, stored) { + return internalAPIError(errors.New("stored close event batch does not match the committed attempt")) + } + cfg.Publish().Events(projectID, stored) + p.discard(projectID, key, fingerprint) + return nil +} + +func sameCloseEventBatch(expected, stored []db.Event) bool { + if len(expected) != len(stored) { + return false + } + for i := range expected { + if expected[i].UID != stored[i].UID || + expected[i].ContentHash != stored[i].ContentHash { + return false + } + } + return true } func tryCloseIdempotencyMatch( diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go index 9c9a5d779..3302819b5 100644 --- a/internal/daemon/handlers_actions_retry_test.go +++ b/internal/daemon/handlers_actions_retry_test.go @@ -64,8 +64,11 @@ func (s *closeRaceStore) CloseIssueGuarded( type lostCloseResponseStore struct { db.Storage - failNext bool - committedEvents []db.Event + failNext bool + failEventLookupOnce bool + failIssueReadOnce bool + committed bool + committedEvents []db.Event } func (s *lostCloseResponseStore) CloseIssueGuarded( @@ -74,12 +77,61 @@ func (s *lostCloseResponseStore) CloseIssueGuarded( issue, events, changed, err := s.Storage.CloseIssueGuarded(ctx, params) if err == nil && changed && s.failNext { s.failNext = false + s.committed = true s.committedEvents = append([]db.Event(nil), events...) return issue, events, changed, errors.New("commit response lost") } return issue, events, changed, err } +func (s *lostCloseResponseStore) EventsByUIDs( + ctx context.Context, projectID int64, uids []string, +) ([]db.Event, error) { + if s.committed && s.failEventLookupOnce { + s.failEventLookupOnce = false + return nil, errors.New("event lookup unavailable") + } + return s.Storage.EventsByUIDs(ctx, projectID, uids) +} + +func (s *lostCloseResponseStore) IssueByID(ctx context.Context, id int64) (db.Issue, error) { + if s.committed && s.failIssueReadOnce { + s.failIssueReadOnce = false + return db.Issue{}, errors.New("issue read unavailable") + } + return s.Storage.IssueByID(ctx, id) +} + +func TestClose_RetryControlsRequireProtocolMarker(t *testing.T) { + _, ts, projectID, issueID := bootstrapProjectWithIssue(t) + response := postWithHeader(t, ts, issueURL(projectID, issueID, "actions/close"), + map[string]string{"Idempotency-Key": "close-without-protocol"}, map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + }) + assertAPIError(t, response.status, response.body, + http.StatusBadRequest, "retry_protocol_required") +} + +func TestClose_RetryProtocolMarkerIsCloseOnly(t *testing.T) { + _, ts, projectID, issueID := bootstrapProjectWithIssue(t) + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", + } + response := postWithHeader(t, ts, issueURL(projectID, issueID, "actions/close"), + map[string]string{"Idempotency-Key": "close-with-protocol"}, body) + requireOK(t, response) + + reopen := postWithHeader(t, ts, issueURL(projectID, issueID, "actions/reopen"), nil, + map[string]any{"actor": "agent-one", "retry_protocol": "close-v1"}) + assert.Equal(t, http.StatusBadRequest, reopen.status, string(reopen.body)) + assert.Contains(t, string(reopen.body), "retry_protocol") +} + func TestClose_IdempotencyReplaysCommittedReceipt(t *testing.T) { h, ts, projectID, issueID := bootstrapProjectWithIssue(t) issue, err := h.DB().IssueByID(context.Background(), issueID) @@ -90,9 +142,10 @@ func TestClose_IdempotencyReplaysCommittedReceipt(t *testing.T) { "If-Match": `"rev-1"`, } body := map[string]any{ - "actor": "agent-one", - "reason": "done", - "message": "Implemented the requested behavior and ran the focused tests.", + "actor": "agent-one", + "reason": "done", + "message": "Implemented the requested behavior and ran the focused tests.", + "retry_protocol": "close-v1", "evidence": []map[string]any{{ "type": "test", "command": "go test ./internal/daemon", }}, @@ -137,9 +190,10 @@ func TestClose_IdempotencyReplaysReceiptAfterSoftDelete(t *testing.T) { path := issueURLRef(projectID, issue.ShortID, "actions/close") headers := map[string]string{"Idempotency-Key": "close-request-then-delete"} body := map[string]any{ - "actor": "agent-one", - "reason": "wontfix", - "message": "Reviewed the request and recorded why the work should stop here.", + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", } first := postWithHeader(t, ts, path, headers, body) @@ -170,9 +224,10 @@ func TestClose_IdempotencyReplaysReceiptAfterMove(t *testing.T) { path := issueURLRef(projectID, issue.ShortID, "actions/close") headers := map[string]string{"Idempotency-Key": "close-request-then-move"} body := map[string]any{ - "actor": "agent-one", - "reason": "wontfix", - "message": "Reviewed the request and recorded why the work should stop here.", + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", } first := postWithHeader(t, ts, path, headers, body) @@ -213,9 +268,10 @@ func TestClose_RejectsKeyWhenAnotherCloseWinsBeforeWrite(t *testing.T) { path := issueURLRef(project.ID, issue.ShortID, "actions/close") headers := map[string]string{"Idempotency-Key": "close-race-1"} body := map[string]any{ - "actor": "agent-one", - "reason": "wontfix", - "message": "Reviewed the request and recorded why the work should stop here.", + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", } first := postWithHeader(t, ts, path, headers, body) @@ -248,9 +304,10 @@ func TestClose_RejectsMoveThatWinsBeforeGuardedWrite(t *testing.T) { response := postWithHeader(t, ts, issueURLRef(source.ID, issue.ShortID, "actions/close"), map[string]string{"Idempotency-Key": "close-move-race-1"}, map[string]any{ - "actor": "agent-one", - "reason": "wontfix", - "message": "Reviewed the request and recorded why the work should stop here.", + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", }) assertAPIError(t, response.status, response.body, http.StatusConflict, "issue_moved") @@ -282,9 +339,10 @@ func TestClose_RecoversCommittedReceiptAndPublishesEventsOnce(t *testing.T) { path := issueURLRef(project.ID, issue.ShortID, "actions/close") headers := map[string]string{"Idempotency-Key": "close-lost-response-1"} body := map[string]any{ - "actor": "agent-one", - "reason": "wontfix", - "message": "Reviewed the request and recorded why the work should stop here.", + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", } first := postWithHeader(t, ts, path, headers, body) @@ -307,14 +365,64 @@ func TestClose_RecoversCommittedReceiptAndPublishesEventsOnce(t *testing.T) { assert.Empty(t, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) } +func TestClose_RetryRecoversUndeliveredCommitWithoutDuplicateEvents(t *testing.T) { + database := openTestDB(t) + project, issue := createClaimHubIssueInDB(t, database.db) + _, err := database.db.AcquireClaim(t.Context(), db.AcquireClaimParams{ + ProjectID: project.ID, IssueRef: issue.ShortID, + Principal: db.ClaimPrincipal{ + HolderInstanceUID: database.db.InstanceUID(), Holder: "agent-one", ClientKind: "cli", + }, + ClaimKind: "hard", Now: time.Now().UTC(), + }) + require.NoError(t, err) + store := &lostCloseResponseStore{ + Storage: database.db, failNext: true, + failEventLookupOnce: true, failIssueReadOnce: true, + } + sink := &recordingSink{} + broadcaster := daemon.NewEventBroadcaster() + subscription := broadcaster.Subscribe(daemon.SubFilter{ProjectID: project.ID}) + defer subscription.Unsub() + ts := startTestServer(t, daemon.ServerConfig{ + DB: store, StartedAt: database.now, Hooks: sink, Broadcaster: broadcaster, + }) + path := issueURLRef(project.ID, issue.ShortID, "actions/close") + headers := map[string]string{"Idempotency-Key": "close-delivery-retry-1"} + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", + } + + first := postWithHeader(t, ts, path, headers, body) + assertAPIError(t, first.status, first.body, http.StatusInternalServerError, "internal") + require.Len(t, store.committedEvents, 2) + assert.Empty(t, sink.snapshot()) + assert.Empty(t, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) + + second := postWithHeader(t, ts, path, headers, body) + assertAPIError(t, second.status, second.body, http.StatusInternalServerError, "internal") + assert.Equal(t, store.committedEvents, sink.snapshot()) + assert.Equal(t, []int64{store.committedEvents[0].ID, store.committedEvents[1].ID}, + drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) + + third := postWithHeader(t, ts, path, headers, body) + requireOK(t, third) + assert.Equal(t, store.committedEvents, sink.snapshot()) + assert.Empty(t, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) +} + func TestClose_IdempotencyRejectsDifferentRequest(t *testing.T) { _, ts, projectID, issueID := bootstrapProjectWithIssue(t) path := issueURL(projectID, issueID, "actions/close") headers := map[string]string{"Idempotency-Key": "close-request-1"} body := map[string]any{ - "actor": "agent-one", - "reason": "wontfix", - "message": "Reviewed the request and recorded why the work should stop here.", + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", } requireOK(t, postWithHeader(t, ts, path, headers, body)) @@ -333,9 +441,10 @@ func TestClose_IdempotencyRejectsDifferentIssueWithSameBody(t *testing.T) { require.NoError(t, err) headers := map[string]string{"Idempotency-Key": "close-request-1"} body := map[string]any{ - "actor": "agent-one", - "reason": "wontfix", - "message": "Reviewed the request and recorded why the work should stop here.", + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", } requireOK(t, postWithHeader(t, ts, issueURLRef(projectID, first.ShortID, "actions/close"), headers, body)) @@ -360,9 +469,10 @@ func TestClose_IfMatchRejectsStaleRevision(t *testing.T) { require.NoError(t, err) body := map[string]any{ - "actor": "agent-one", - "reason": "done", - "message": "Implemented the requested behavior and ran the focused tests.", + "actor": "agent-one", + "reason": "done", + "message": "Implemented the requested behavior and ran the focused tests.", + "retry_protocol": "close-v1", "evidence": []map[string]any{{ "type": "test", "command": "go test ./internal/daemon", }}, @@ -412,6 +522,7 @@ func TestClose_IfMatchRejectsStaleRevisionAfterAnotherClose(t *testing.T) { } requireOK(t, postWithHeader(t, ts, path, nil, body)) + body["retry_protocol"] = "close-v1" response := postWithHeader(t, ts, path, map[string]string{"If-Match": `"rev-1"`}, body) assertAPIError(t, response.status, response.body, diff --git a/internal/daemon/handlers_comments.go b/internal/daemon/handlers_comments.go index 082c26bf6..ea6509beb 100644 --- a/internal/daemon/handlers_comments.go +++ b/internal/daemon/handlers_comments.go @@ -66,6 +66,11 @@ func registerCommentsHandlers(humaAPI huma.API, cfg ServerConfig) { if err != nil { return nil, internalAPIError(err) } + if _, err := authorizeHostProjectScope( + ctx, []int64{updated.ProjectID}, nil, false, + ); err != nil { + return nil, err + } out := &api.CommentResponse{} out.Body.Issue = updated out.Body.Comment = match.Comment diff --git a/internal/daemon/handlers_comments_test.go b/internal/daemon/handlers_comments_test.go index 19a804d99..7cf170205 100644 --- a/internal/daemon/handlers_comments_test.go +++ b/internal/daemon/handlers_comments_test.go @@ -3,14 +3,32 @@ package daemon_test import ( "context" "encoding/json" + "net/http" + "net/http/httptest" + "slices" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/kata/internal/daemon" "go.kenn.io/kata/internal/db" ) +type commentProjectHostAccess struct { + deniedProjectID int64 +} + +func (a commentProjectHostAccess) Authorize( + _ context.Context, + request daemon.HostAccessRequest, +) (daemon.HostAccessDecision, error) { + if slices.Contains(request.Operation.ProjectIDs, a.deniedProjectID) { + return daemon.HostAccessDecision{}, daemon.ErrHostAccessDenied + } + return daemon.HostAccessDecision{}, nil +} + func TestCommentEndpoint_AppendsAndEmitsEvent(t *testing.T) { _, ts, pid, num := bootstrapProjectWithIssue(t) @@ -87,6 +105,41 @@ func TestCommentEndpoint_IdempotencyReplaysCommittedCommentAfterMove(t *testing. require.Len(t, comments, 1) } +func TestCommentEndpoint_IdempotencyReauthorizesMovedIssue(t *testing.T) { + dbh, initialServer, sourceProjectID, issueID := bootstrapProjectWithIssue(t) + issue, err := dbh.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + body := map[string]any{"actor": "agent", "body": "first comment"} + headers := map[string]string{"Idempotency-Key": "comment-request-before-move"} + path := issueURLRef(sourceProjectID, issue.UID, "comments") + requireOK(t, postWithHeader(t, initialServer, path, headers, body)) + + target, err := dbh.DB().CreateProject(t.Context(), "denied-target") + require.NoError(t, err) + issue, err = dbh.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + _, err = dbh.DB().MoveIssueProject(t.Context(), db.MoveIssueProjectIn{ + IssueID: issue.ID, FromProjectID: sourceProjectID, ToProjectID: target.ID, + IfMatchRev: issue.Revision, Actor: "coordinator", + }) + require.NoError(t, err) + + server := daemon.NewServer(daemon.ServerConfig{ + DB: dbh.DB(), StartedAt: time.Now(), + HostAccess: commentProjectHostAccess{deniedProjectID: target.ID}, + }) + hostServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + ctx := daemon.WithPrincipal(request.Context(), daemon.Principal{ + Kind: daemon.PrincipalHost, Subject: "host-user", Actor: "agent", + }) + server.Handler().ServeHTTP(writer, request.WithContext(ctx)) + })) + t.Cleanup(hostServer.Close) + + retry := postWithHeader(t, hostServer, path, headers, body) + assertAPIError(t, retry.status, retry.body, http.StatusNotFound, "not_found") +} + func TestCommentEndpoint_IdempotencyRejectsDifferentBody(t *testing.T) { _, ts, pid, issueID := bootstrapProjectWithIssue(t) path := issueURL(pid, issueID, "comments") diff --git a/internal/mcp/close_retry_test.go b/internal/mcp/close_retry_test.go index af674ba47..5e135dd46 100644 --- a/internal/mcp/close_retry_test.go +++ b/internal/mcp/close_retry_test.go @@ -1,8 +1,7 @@ package mcpserver import ( - "context" - "errors" + "encoding/json" "net/http" "strings" "testing" @@ -11,7 +10,7 @@ import ( ) func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { - var idempotencyKey, ifMatch string + var idempotencyKey, ifMatch, retryProtocol string client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { switch { case request.URL.Path == "/api/v1/projects": @@ -21,6 +20,9 @@ func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { case strings.HasSuffix(request.URL.Path, "/actions/close"): idempotencyKey = request.Header.Get("Idempotency-Key") ifMatch = request.Header.Get("If-Match") + var body map[string]any + require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) + retryProtocol, _ = body["retry_protocol"].(string) issue := issueJSON(1, "spoke-project", "abc1") issue["status"] = "closed" writeJSON(writer, map[string]any{ @@ -36,7 +38,6 @@ func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { }) handlers := toolHandlers{options: Options{ Client: client, Scope: NewAllScope(), Actor: "example-agent", - CheckCloseRetrySupport: func(context.Context) error { return nil }, }} revision := int64(7) _, output, err := handlers.close(t.Context(), nil, CloseInput{ @@ -47,6 +48,7 @@ func TestCloseForwardsRetryGuardsAndReturnsOriginalReceipt(t *testing.T) { require.NoError(t, err) require.Equal(t, "close-request-1", idempotencyKey) require.Equal(t, `"rev-7"`, ifMatch) + require.Equal(t, "close-v1", retryProtocol) require.NotNil(t, output.Reused) require.True(t, *output.Reused) require.NotNil(t, output.Event) @@ -83,7 +85,6 @@ func TestCloseRejectsReusedIssueMovedOutsideFixedScope(t *testing.T) { require.NoError(t, err) handlers := toolHandlers{options: Options{ Client: client, Scope: scope, Actor: "example-agent", - CheckCloseRetrySupport: func(context.Context) error { return nil }, }} _, _, err = handlers.close(t.Context(), nil, CloseInput{ @@ -125,7 +126,6 @@ func TestCloseUsesCurrentProjectForReusedIssueMovedWithinScope(t *testing.T) { require.NoError(t, err) handlers := toolHandlers{options: Options{ Client: client, Scope: scope, Actor: "example-agent", - CheckCloseRetrySupport: func(context.Context) error { return nil }, }} _, output, err := handlers.close(t.Context(), nil, CloseInput{ @@ -138,8 +138,9 @@ func TestCloseUsesCurrentProjectForReusedIssueMovedWithinScope(t *testing.T) { require.Equal(t, "target-project#def1", output.Issue.QualifiedRef) } -func TestCloseRechecksRetrySupportEachCall(t *testing.T) { - var checks, closeCalls int +func TestCloseRetryProtocolMakesLegacyDaemonRejectRequest(t *testing.T) { + var closeCalls, mutations int + var retryProtocol string client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { switch { case request.URL.Path == "/api/v1/projects": @@ -148,6 +149,21 @@ func TestCloseRechecksRetrySupportEachCall(t *testing.T) { }}) case strings.HasSuffix(request.URL.Path, "/actions/close"): closeCalls++ + var body map[string]any + require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) + retryProtocol, _ = body["retry_protocol"].(string) + if retryProtocol != "" { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusBadRequest) + writeJSON(writer, map[string]any{ + "status": http.StatusBadRequest, + "error": map[string]any{ + "code": "validation", "message": "retry_protocol: unexpected property", + }, + }) + return + } + mutations++ writeJSON(writer, map[string]any{ "issue": issueJSON(1, "spoke-project", "abc1"), "changed": true, }) @@ -155,44 +171,6 @@ func TestCloseRechecksRetrySupportEachCall(t *testing.T) { http.NotFound(writer, request) } }) - handlers := toolHandlers{options: Options{ - Client: client, Scope: NewAllScope(), Actor: "example-agent", - CheckCloseRetrySupport: func(context.Context) error { - checks++ - if checks > 1 { - return errors.New("daemon API became incompatible") - } - return nil - }, - }} - input := CloseInput{ - Ref: "spoke-project#abc1", Reason: "wontfix", - Message: "Reviewed the request and recorded why the work should stop here.", - IdempotencyKey: "close-request-1", - } - _, _, err := handlers.close(t.Context(), nil, input) - require.NoError(t, err) - _, _, err = handlers.close(t.Context(), nil, input) - require.ErrorContains(t, err, "daemon API became incompatible") - require.Equal(t, 2, checks) - require.Equal(t, 1, closeCalls) -} - -func TestCloseRejectsRetryGuardsWhenDaemonDoesNotSupportThem(t *testing.T) { - var closeCalls int - client := reviewClient(t, func(writer http.ResponseWriter, request *http.Request) { - switch { - case request.URL.Path == "/api/v1/projects": - writeJSON(writer, map[string]any{"projects": []any{ - projectJSON(1, "01HAAAAAAAAAAAAAAAAAAAAAAA", "spoke-project"), - }}) - case strings.HasSuffix(request.URL.Path, "/actions/close"): - closeCalls++ - writeJSON(writer, map[string]any{"changed": true}) - default: - http.NotFound(writer, request) - } - }) handlers := toolHandlers{options: Options{ Client: client, Scope: NewAllScope(), Actor: "example-agent", }} @@ -201,8 +179,10 @@ func TestCloseRejectsRetryGuardsWhenDaemonDoesNotSupportThem(t *testing.T) { Message: "Reviewed the request and recorded why the work should stop here.", IdempotencyKey: "close-request-1", }) - require.ErrorContains(t, err, "daemon does not support kata.close retry controls") - require.Zero(t, closeCalls) + require.Error(t, err) + require.Equal(t, "close-v1", retryProtocol) + require.Equal(t, 1, closeCalls) + require.Zero(t, mutations) } func closeRetryEventJSON() map[string]any { diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index fd8f5eb6f..e2699a73c 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -753,20 +753,12 @@ func (h toolHandlers) patchMetadata(ctx context.Context, project ProjectIdentity } func (h toolHandlers) close(ctx context.Context, _ *sdkmcp.CallToolRequest, input CloseInput) (*sdkmcp.CallToolResult, MutationOutput, error) { - if input.IdempotencyKey != "" || input.Revision != nil { - if h.options.CheckCloseRetrySupport == nil { - return nil, MutationOutput{}, errors.New("daemon does not support kata.close retry controls; upgrade the daemon") - } - if err := h.options.CheckCloseRetrySupport(ctx); err != nil { - return nil, MutationOutput{}, err - } - } project, ref, err := h.options.Scope.IssueTarget(ctx, h.options.Client, input.Ref, true) if err != nil { return nil, MutationOutput{}, err } - reason := generated.ActionRequestBodyReason(input.Reason) - if err := reason.Validate(); err != nil || reason == generated.Empty { + reason := generated.CloseActionRequestBodyReason(input.Reason) + if err := reason.Validate(); err != nil || reason == generated.CloseActionRequestBodyReasonEmpty { return nil, MutationOutput{}, fmt.Errorf("reason must be one of done, wontfix, duplicate, superseded, or audit-no-change") } if strings.TrimSpace(input.Message) == "" { @@ -781,8 +773,11 @@ func (h toolHandlers) close(ctx context.Context, _ *sdkmcp.CallToolRequest, inpu evidence = append(evidence, converted) } var headers *generated.CloseIssueHeaders + var retryProtocol *generated.CloseActionRequestBodyRetryProtocol if input.IdempotencyKey != "" || input.Revision != nil { headers = &generated.CloseIssueHeaders{} + protocol := generated.CloseV1 + retryProtocol = &protocol if input.IdempotencyKey != "" { headers.IdempotencyKey = &input.IdempotencyKey } @@ -797,11 +792,12 @@ func (h toolHandlers) close(ctx context.Context, _ *sdkmcp.CallToolRequest, inpu response, err := h.options.Client.CloseIssue(ctx, &generated.CloseIssueRequestOptions{ PathParams: &generated.CloseIssuePath{ProjectID: project.ID, Ref: ref}, Body: &generated.CloseIssueBody{ - Actor: &h.options.Actor, - Reason: &reason, - Message: &input.Message, - Evidence: evidence, - DryRun: optionalTrue(input.DryRun), + Actor: &h.options.Actor, + Reason: &reason, + Message: &input.Message, + Evidence: evidence, + DryRun: optionalTrue(input.DryRun), + RetryProtocol: retryProtocol, }, Header: headers, }) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index d7468d5a4..e067dc90c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -27,16 +27,15 @@ const ( // Options fixes the Kata daemon scope and actor for one MCP process. type Options struct { - Client *kataclient.Client - LongRunningClient *kataclient.Client - Scope *Scope - ProjectID int64 - ProjectName string - Actor string - Version string - StorageAdmin *storageadmin.Admin - EnableTokenAdmin bool - CheckCloseRetrySupport func(context.Context) error + Client *kataclient.Client + LongRunningClient *kataclient.Client + Scope *Scope + ProjectID int64 + ProjectName string + Actor string + Version string + StorageAdmin *storageadmin.Admin + EnableTokenAdmin bool } // New creates a tools-only MCP server for one startup project scope. diff --git a/pkg/client/generated/enums.go b/pkg/client/generated/enums.go index c764bcc8e..ec29b3908 100644 --- a/pkg/client/generated/enums.go +++ b/pkg/client/generated/enums.go @@ -46,6 +46,61 @@ func (a ActionRequestBodySource) Validate() error { } } +type CloseActionRequestBodyReason string + +const ( + CloseActionRequestBodyReasonAuditNoChange CloseActionRequestBodyReason = "audit-no-change" + CloseActionRequestBodyReasonDone CloseActionRequestBodyReason = "done" + CloseActionRequestBodyReasonDuplicate CloseActionRequestBodyReason = "duplicate" + CloseActionRequestBodyReasonEmpty CloseActionRequestBodyReason = "" + CloseActionRequestBodyReasonSuperseded CloseActionRequestBodyReason = "superseded" + CloseActionRequestBodyReasonWontfix CloseActionRequestBodyReason = "wontfix" +) + +// Validate checks if the CloseActionRequestBodyReason value is valid +func (c CloseActionRequestBodyReason) Validate() error { + switch c { + case CloseActionRequestBodyReasonAuditNoChange, CloseActionRequestBodyReasonDone, CloseActionRequestBodyReasonDuplicate, CloseActionRequestBodyReasonEmpty, CloseActionRequestBodyReasonSuperseded, CloseActionRequestBodyReasonWontfix: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CloseActionRequestBodyReason value, got: %v", c)) + } +} + +type CloseActionRequestBodyRetryProtocol string + +const ( + CloseActionRequestBodyRetryProtocolEmpty CloseActionRequestBodyRetryProtocol = "" + CloseV1 CloseActionRequestBodyRetryProtocol = "close-v1" +) + +// Validate checks if the CloseActionRequestBodyRetryProtocol value is valid +func (c CloseActionRequestBodyRetryProtocol) Validate() error { + switch c { + case CloseActionRequestBodyRetryProtocolEmpty, CloseV1: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CloseActionRequestBodyRetryProtocol value, got: %v", c)) + } +} + +type CloseActionRequestBodySource string + +const ( + CloseActionRequestBodySourceEmpty CloseActionRequestBodySource = "" + CloseActionRequestBodySourceTui CloseActionRequestBodySource = "tui" +) + +// Validate checks if the CloseActionRequestBodySource value is valid +func (c CloseActionRequestBodySource) Validate() error { + switch c { + case CloseActionRequestBodySourceEmpty, CloseActionRequestBodySourceTui: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid CloseActionRequestBodySource value, got: %v", c)) + } +} + type CreateInitialLinkBodyType string const ( diff --git a/pkg/client/generated/payloads.go b/pkg/client/generated/payloads.go index 7982769f9..71a617456 100644 --- a/pkg/client/generated/payloads.go +++ b/pkg/client/generated/payloads.go @@ -48,7 +48,7 @@ type AssignIssueBody = AssignRequestBody type ClaimIssueBody = ClaimRequestBody -type CloseIssueBody = ActionRequestBody +type CloseIssueBody = CloseActionRequestBody type DeleteIssueBody = DestructiveActionRequestBody diff --git a/pkg/client/generated/types.go b/pkg/client/generated/types.go index 59fc4dfa7..06fa5ef41 100644 --- a/pkg/client/generated/types.go +++ b/pkg/client/generated/types.go @@ -460,6 +460,52 @@ func (c ClaimViolationOut) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(c)) } +type CloseActionRequestBody struct { + Actor *string `json:"actor,omitempty"` + DryRun *bool `json:"dry_run,omitempty"` + Evidence []Evidence `json:"evidence,omitempty"` + Message *string `json:"message,omitempty"` + Reason *CloseActionRequestBodyReason `json:"reason,omitempty"` + RetryProtocol *CloseActionRequestBodyRetryProtocol `json:"retry_protocol,omitempty"` + Source *CloseActionRequestBodySource `json:"source,omitempty"` +} + +func (c CloseActionRequestBody) Validate() error { + var errors runtime.ValidationErrors + for i, item := range c.Evidence { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Evidence[%d]", i), err) + } + } + } + if c.Reason != nil { + if v, ok := any(c.Reason).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Reason", err) + } + } + } + if c.RetryProtocol != nil { + if v, ok := any(c.RetryProtocol).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("RetryProtocol", err) + } + } + } + if c.Source != nil { + if v, ok := any(c.Source).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Source", err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type Comment struct { Author string `json:"author" validate:"required"` Body string `json:"body" validate:"required"` diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml index 042754eec..984219e7e 100644 --- a/pkg/client/openapi.yaml +++ b/pkg/client/openapi.yaml @@ -284,6 +284,40 @@ components: - issue_uid - at type: object + CloseActionRequestBody: + additionalProperties: false + properties: + actor: + type: string + dry_run: + type: boolean + evidence: + items: + $ref: "#/components/schemas/Evidence" + nullable: true + type: array + message: + type: string + reason: + enum: + - done + - wontfix + - duplicate + - superseded + - audit-no-change + - "" + type: string + retry_protocol: + enum: + - close-v1 + - "" + type: string + source: + enum: + - tui + - "" + type: string + type: object Comment: properties: author: @@ -6043,7 +6077,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ActionRequestBody" + $ref: "#/components/schemas/CloseActionRequestBody" required: true responses: "200": diff --git a/web/src/lib/api/schema.d.ts b/web/src/lib/api/schema.d.ts index 6cebc17e2..4f431e24f 100644 --- a/web/src/lib/api/schema.d.ts +++ b/web/src/lib/api/schema.d.ts @@ -1575,6 +1575,18 @@ export interface components { } & { [key: string]: unknown } + CloseActionRequestBody: { + actor?: string + dry_run?: boolean + evidence?: components['schemas']['Evidence'][] | null + message?: string + /** @enum {string} */ + reason?: 'done' | 'wontfix' | 'duplicate' | 'superseded' | 'audit-no-change' | '' + /** @enum {string} */ + retry_protocol?: 'close-v1' | '' + /** @enum {string} */ + source?: 'tui' | '' + } Comment: { author: string body: string @@ -5366,7 +5378,7 @@ export interface operations { } requestBody: { content: { - 'application/json': components['schemas']['ActionRequestBody'] + 'application/json': components['schemas']['CloseActionRequestBody'] } } responses: { From 897a5801039ae3d2cd6910fe0d09bbb14fb291dd Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 17:14:58 -0500 Subject: [PATCH 10/14] Make PostgreSQL idempotency lock keys binary-safe --- internal/db/dbtest/conformance_core.go | 8 ++++++++ internal/db/pgstore/idempotency_lock.go | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/db/dbtest/conformance_core.go b/internal/db/dbtest/conformance_core.go index 068631e67..86fbdf45b 100644 --- a/internal/db/dbtest/conformance_core.go +++ b/internal/db/dbtest/conformance_core.go @@ -427,6 +427,14 @@ func checkIdempotency(t *testing.T, store db.Storage) error { } assert.Equal(t, staleIssue.Revision+1, conflict.CurrentRevision) + nulRelease, err := store.AcquireIdempotencyLock(ctx, 0, "issue-uid\x00comment-request") + if err != nil { + return fmt.Errorf("acquire idempotency lock containing NUL: %w", err) + } + if err := nulRelease(); err != nil { + return fmt.Errorf("release idempotency lock containing NUL: %w", err) + } + releaseFirst, err := store.AcquireIdempotencyLock(ctx, project.ID, "serialized-request") if err != nil { return fmt.Errorf("acquire first idempotency lock: %w", err) diff --git a/internal/db/pgstore/idempotency_lock.go b/internal/db/pgstore/idempotency_lock.go index 92a17ece9..9f6b14cb9 100644 --- a/internal/db/pgstore/idempotency_lock.go +++ b/internal/db/pgstore/idempotency_lock.go @@ -2,6 +2,7 @@ package pgstore import ( "context" + "crypto/sha256" "errors" "fmt" "time" @@ -23,7 +24,8 @@ func (s *Store) AcquireIdempotencyLock( if s.idempotencyDB == nil { return nil, errors.New("postgres idempotency coordinator is unavailable") } - lockIdentity := fmt.Sprintf("kata:pgstore:idempotency:%s:%d:%s", s.schema, projectID, key) + rawLockIdentity := fmt.Sprintf("kata:pgstore:idempotency:%s:%d:%s", s.schema, projectID, key) + lockIdentity := fmt.Sprintf("%x", sha256.Sum256([]byte(rawLockIdentity))) for { conn, err := s.idempotencyDB.Conn(ctx) if err != nil { From 5c028570cbaa1609f120534c5f9a2fb924e35c65 Mon Sep 17 00:00:00 2001 From: naveenspark Date: Wed, 2 Sep 2026 18:34:11 -0500 Subject: [PATCH 11/14] Persist close event delivery across daemons --- docs/operations/postgres.md | 7 + internal/daemon/handlers_actions.go | 147 +++++-------- .../daemon/handlers_actions_retry_test.go | 58 +++++ internal/db/dbtest/conformance.go | 2 +- internal/db/dbtest/conformance_core.go | 81 ++++++- internal/db/errors.go | 12 + internal/db/params.go | 22 ++ internal/db/pgstore/close_event_deliveries.go | 168 ++++++++++++++ internal/db/pgstore/foundation_test.go | 72 +++++- internal/db/pgstore/issue_lifecycle.go | 3 + internal/db/pgstore/migrations.go | 9 + .../000027_close_event_deliveries.up.sql | 22 ++ internal/db/pgstore/schema.sql | 23 ++ internal/db/pgstore/schema_manifest.go | 8 +- internal/db/pgstore/stubgen/main.go | 3 + internal/db/pgstore/stubgen/main_test.go | 5 +- internal/db/schema_version.go | 2 +- .../db/sqlitestore/close_event_deliveries.go | 205 ++++++++++++++++++ internal/db/sqlitestore/federation_test.go | 2 +- internal/db/sqlitestore/github_sync_test.go | 6 +- internal/db/sqlitestore/queries.go | 3 + internal/db/sqlitestore/schema.sql | 24 ++ .../sqlitestore/schema_completeness_test.go | 2 +- internal/db/storage.go | 3 + internal/db/types.go | 9 + 25 files changed, 786 insertions(+), 112 deletions(-) create mode 100644 internal/db/pgstore/close_event_deliveries.go create mode 100644 internal/db/pgstore/migrations/000027_close_event_deliveries.up.sql create mode 100644 internal/db/sqlitestore/close_event_deliveries.go diff --git a/docs/operations/postgres.md b/docs/operations/postgres.md index 2d93f0f1f..39a1cf494 100644 --- a/docs/operations/postgres.md +++ b/docs/operations/postgres.md @@ -215,6 +215,13 @@ attacker. It is not the production private-network posture. Until a release explicitly documents an online migration, treat every schema upgrade as an offline operation: +Schema version 27 adds `close_event_deliveries`, which keeps the ordered event +identities and publish claim for retry-safe keyed closes. Upgrade it with the +schema-owner role while all daemons are stopped. The runtime role needs the +standard table grants described above before daemons restart. A version 26 +binary cannot open the upgraded schema. Rollback requires the pre-upgrade +snapshot and the matching version 26 binary. + 1. Stop every daemon or scale all replicas to zero. The migration advisory lock serializes migrators; it does not quiesce ordinary writes from an old binary. 2. Take a database-native snapshot and a JSONL export. Keep the native snapshot diff --git a/internal/daemon/handlers_actions.go b/internal/daemon/handlers_actions.go index 6e46bd5b6..8d1f54b7f 100644 --- a/internal/daemon/handlers_actions.go +++ b/internal/daemon/handlers_actions.go @@ -7,13 +7,13 @@ import ( "encoding/json" "errors" "fmt" - "sync" "time" "github.com/danielgtaylor/huma/v2" "go.kenn.io/kata/internal/api" "go.kenn.io/kata/internal/db" + katauid "go.kenn.io/kata/internal/uid" ) // registerActionsHandlers installs POST /actions/close and /actions/reopen. @@ -21,7 +21,6 @@ import ( // issue is already in the target state; both fields propagate verbatim into // the MutationResponse envelope. func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { - var closeDeliveries pendingCloseDeliveries huma.Register(humaAPI, huma.Operation{ OperationID: "closeIssue", Method: "POST", @@ -72,17 +71,6 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { return nil, internalAPIError(err) } defer func() { _ = release() }() - if pending, ok := closeDeliveries.get(in.ProjectID, in.IdempotencyKey); ok { - retryFingerprint := closeIdempotencyFingerprint( - pending.issueUID, in.Ref, actor, in.Body.Reason, in.Body.Message, in.Body.Source, - in.Body.Evidence, in.Body.DryRun, ifMatchRev) - if err := closeDeliveries.publishCommitted( - ctx, cfg, in.ProjectID, in.IdempotencyKey, retryFingerprint, - ); err != nil { - return nil, err - } - } - match, err := lookupCloseIdempotencyMatch(ctx, cfg, in.ProjectID, in.IdempotencyKey) if err != nil { return nil, err @@ -91,6 +79,13 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { idempotencyFingerprint = closeIdempotencyFingerprint( match.IssueUID, in.Ref, actor, in.Body.Reason, in.Body.Message, in.Body.Source, in.Body.Evidence, in.Body.DryRun, ifMatchRev) + if match.Fingerprint == idempotencyFingerprint { + if err := publishCloseEventDelivery( + ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint, + ); err != nil { + return nil, err + } + } return closeIdempotencyResponse(ctx, cfg, match, idempotencyFingerprint) } } @@ -210,18 +205,13 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { if len(events) > 0 { evt = &events[0] } - if err != nil && in.IdempotencyKey != "" && len(events) > 0 { - closeDeliveries.remember(in.ProjectID, in.IdempotencyKey, - issue.UID, idempotencyFingerprint, events) - } return err }) - if err == nil && changed { - closeDeliveries.discard(in.ProjectID, in.IdempotencyKey, idempotencyFingerprint) + if err == nil && changed && in.IdempotencyKey == "" { cfg.Publish().Events(in.ProjectID, events) } - if in.IdempotencyKey != "" && (err != nil || !changed) { - if recoveryErr := closeDeliveries.publishCommitted( + if in.IdempotencyKey != "" { + if recoveryErr := publishCloseEventDelivery( ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint, ); recoveryErr != nil { return nil, recoveryErr @@ -337,94 +327,61 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { }) } -type pendingCloseDeliveryKey struct { - projectID int64 - idempotencyKey string -} - -type pendingCloseDelivery struct { - issueUID string - fingerprint string - events []db.Event -} - -// pendingCloseDeliveries retains the event identities returned by a close -// whose commit response was ambiguous. A later exact retry can verify those -// events in storage and deliver them before it returns the stored receipt. -type pendingCloseDeliveries struct { - mu sync.Mutex - pending map[pendingCloseDeliveryKey]pendingCloseDelivery -} - -func (p *pendingCloseDeliveries) remember( - projectID int64, key, issueUID, fingerprint string, events []db.Event, -) { - p.mu.Lock() - defer p.mu.Unlock() - if p.pending == nil { - p.pending = make(map[pendingCloseDeliveryKey]pendingCloseDelivery) - } - p.pending[pendingCloseDeliveryKey{projectID: projectID, idempotencyKey: key}] = pendingCloseDelivery{ - issueUID: issueUID, fingerprint: fingerprint, events: append([]db.Event(nil), events...), - } -} - -func (p *pendingCloseDeliveries) get( - projectID int64, key string, -) (pendingCloseDelivery, bool) { - p.mu.Lock() - defer p.mu.Unlock() - delivery, ok := p.pending[pendingCloseDeliveryKey{projectID: projectID, idempotencyKey: key}] - return delivery, ok -} - -func (p *pendingCloseDeliveries) discard(projectID int64, key, fingerprint string) { - p.mu.Lock() - defer p.mu.Unlock() - mapKey := pendingCloseDeliveryKey{projectID: projectID, idempotencyKey: key} - if delivery, ok := p.pending[mapKey]; ok && delivery.fingerprint == fingerprint { - delete(p.pending, mapKey) - } -} +const closeEventDeliveryClaimLease = 30 * time.Second -func (p *pendingCloseDeliveries) publishCommitted( +func publishCloseEventDelivery( ctx context.Context, cfg ServerConfig, projectID int64, key, fingerprint string, ) error { - delivery, ok := p.get(projectID, key) - if !ok || delivery.fingerprint != fingerprint { - return nil - } - uids := make([]string, len(delivery.events)) - for i := range delivery.events { - uids[i] = delivery.events[i].UID + claimToken, err := katauid.New() + if err != nil { + return internalAPIError(fmt.Errorf("generate close event delivery claim: %w", err)) } - stored, err := cfg.DB.EventsByUIDs(ctx, projectID, uids) + claimedAt := time.Now().UTC() + claim, err := cfg.DB.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ + ProjectID: projectID, IdempotencyKey: key, Fingerprint: fingerprint, + ClaimToken: claimToken, ClaimedAt: claimedAt, + ClaimExpiresAt: claimedAt.Add(closeEventDeliveryClaimLease), + }) if errors.Is(err, db.ErrNotFound) { - p.discard(projectID, key, fingerprint) + // Receipts created before durable delivery tracking have no batch row. return nil } if err != nil { return internalAPIError(err) } - if !sameCloseEventBatch(delivery.events, stored) { - return internalAPIError(errors.New("stored close event batch does not match the committed attempt")) + if claim.Delivered { + return nil } - cfg.Publish().Events(projectID, stored) - p.discard(projectID, key, fingerprint) - return nil -} - -func sameCloseEventBatch(expected, stored []db.Event) bool { - if len(expected) != len(stored) { - return false + if !claim.Acquired { + return internalAPIError(db.ErrCloseEventDeliveryClaimActive) + } + releaseClaim := func(cause error) error { + releaseErr := cfg.DB.ReleaseCloseEventDeliveryClaim(ctx, db.CloseEventDeliveryClaimUpdateParams{ + ProjectID: projectID, IdempotencyKey: key, Fingerprint: fingerprint, + ClaimToken: claimToken, At: time.Now().UTC(), + }) + return internalAPIError(errors.Join(cause, releaseErr)) + } + stored, err := cfg.DB.EventsByUIDs(ctx, projectID, claim.EventUIDs) + if err != nil { + return releaseClaim(err) } - for i := range expected { - if expected[i].UID != stored[i].UID || - expected[i].ContentHash != stored[i].ContentHash { - return false + if len(stored) != len(claim.EventUIDs) { + return releaseClaim(errors.New("stored close event batch is incomplete")) + } + for i := range stored { + if stored[i].UID != claim.EventUIDs[i] { + return releaseClaim(errors.New("stored close event batch is out of order")) } } - return true + cfg.Publish().Events(projectID, stored) + if err := cfg.DB.CompleteCloseEventDelivery(ctx, db.CloseEventDeliveryClaimUpdateParams{ + ProjectID: projectID, IdempotencyKey: key, Fingerprint: fingerprint, + ClaimToken: claimToken, At: time.Now().UTC(), + }); err != nil { + return internalAPIError(err) + } + return nil } func tryCloseIdempotencyMatch( diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go index 3302819b5..9a9de92f2 100644 --- a/internal/daemon/handlers_actions_retry_test.go +++ b/internal/daemon/handlers_actions_retry_test.go @@ -414,6 +414,64 @@ func TestClose_RetryRecoversUndeliveredCommitWithoutDuplicateEvents(t *testing.T assert.Empty(t, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) } +func TestClose_FreshServerRecoversUndeliveredCommitWithoutDuplicateEvents(t *testing.T) { + database := openTestDB(t) + project, issue := createClaimHubIssueInDB(t, database.db) + _, err := database.db.AcquireClaim(t.Context(), db.AcquireClaimParams{ + ProjectID: project.ID, IssueRef: issue.ShortID, + Principal: db.ClaimPrincipal{ + HolderInstanceUID: database.db.InstanceUID(), Holder: "agent-one", ClientKind: "cli", + }, + ClaimKind: "hard", Now: time.Now().UTC(), + }) + require.NoError(t, err) + store := &lostCloseResponseStore{ + Storage: database.db, failNext: true, + failEventLookupOnce: true, + } + firstSink := &recordingSink{} + firstBroadcaster := daemon.NewEventBroadcaster() + firstSubscription := firstBroadcaster.Subscribe(daemon.SubFilter{ProjectID: project.ID}) + defer firstSubscription.Unsub() + firstServer := startTestServer(t, daemon.ServerConfig{ + DB: store, StartedAt: database.now, Hooks: firstSink, Broadcaster: firstBroadcaster, + }) + path := issueURLRef(project.ID, issue.ShortID, "actions/close") + headers := map[string]string{"Idempotency-Key": "close-fresh-server-retry-1"} + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", + } + + first := postWithHeader(t, firstServer, path, headers, body) + assertAPIError(t, first.status, first.body, http.StatusInternalServerError, "internal") + require.Len(t, store.committedEvents, 2) + assert.Empty(t, firstSink.snapshot()) + assert.Empty(t, drainBroadcastIDs(t, firstSubscription.Ch, 50*time.Millisecond)) + firstServer.Close() + + secondSink := &recordingSink{} + secondBroadcaster := daemon.NewEventBroadcaster() + secondSubscription := secondBroadcaster.Subscribe(daemon.SubFilter{ProjectID: project.ID}) + defer secondSubscription.Unsub() + secondServer := startTestServer(t, daemon.ServerConfig{ + DB: database.db, StartedAt: database.now, Hooks: secondSink, Broadcaster: secondBroadcaster, + }) + + second := postWithHeader(t, secondServer, path, headers, body) + requireOK(t, second) + assert.Equal(t, store.committedEvents, secondSink.snapshot()) + assert.Equal(t, []int64{store.committedEvents[0].ID, store.committedEvents[1].ID}, + drainBroadcastIDs(t, secondSubscription.Ch, 50*time.Millisecond)) + + third := postWithHeader(t, secondServer, path, headers, body) + requireOK(t, third) + assert.Equal(t, store.committedEvents, secondSink.snapshot()) + assert.Empty(t, drainBroadcastIDs(t, secondSubscription.Ch, 50*time.Millisecond)) +} + func TestClose_IdempotencyRejectsDifferentRequest(t *testing.T) { _, ts, projectID, issueID := bootstrapProjectWithIssue(t) path := issueURL(projectID, issueID, "actions/close") diff --git a/internal/db/dbtest/conformance.go b/internal/db/dbtest/conformance.go index d4463be62..80018efa7 100644 --- a/internal/db/dbtest/conformance.go +++ b/internal/db/dbtest/conformance.go @@ -71,7 +71,7 @@ var storageScenarios = []scenario{ }, { name: "idempotency", - methods: []string{"AcquireIdempotencyLock", "CreateComment", "CreateIssue", "CreateProject", "LookupCommentIdempotency", "LookupIdempotency", "LookupIssueMutationIdempotency"}, + methods: []string{"AcquireIdempotencyLock", "ClaimCloseEventDelivery", "CompleteCloseEventDelivery", "CreateComment", "CreateIssue", "CreateProject", "LookupCommentIdempotency", "LookupIdempotency", "LookupIssueMutationIdempotency", "ReleaseCloseEventDeliveryClaim"}, run: checkIdempotency, }, { diff --git a/internal/db/dbtest/conformance_core.go b/internal/db/dbtest/conformance_core.go index 86fbdf45b..a084eac46 100644 --- a/internal/db/dbtest/conformance_core.go +++ b/internal/db/dbtest/conformance_core.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync" "testing" "time" @@ -350,10 +351,11 @@ func checkIdempotency(t *testing.T, store db.Storage) error { } assert.Nil(t, missingComment) + closeFingerprint := strings.Repeat("a", 64) closed, closeEvents, changed, err := store.CloseIssueGuarded(ctx, db.CloseIssueParams{ IssueID: issue.ID, Reason: "wontfix", Actor: "conformance-agent", Message: "Recorded the reason for stopping this conformance task.", - IdempotencyKey: "close-request-1", IdempotencyFingerprint: "close-fingerprint-1", + IdempotencyKey: "close-request-1", IdempotencyFingerprint: closeFingerprint, IfMatchRev: new(issue.Revision), }) if err != nil { @@ -370,7 +372,82 @@ func checkIdempotency(t *testing.T, store db.Storage) error { require.NotNil(t, closeMatch) assert.Equal(t, issue.ID, closeMatch.IssueID) assert.Equal(t, closeEvents[0].UID, closeMatch.Event.UID) - assert.Equal(t, "close-fingerprint-1", closeMatch.Fingerprint) + assert.Equal(t, closeFingerprint, closeMatch.Fingerprint) + + claimAt := time.Date(2026, time.September, 2, 12, 0, 0, 0, time.UTC) + firstClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ + ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, + ClaimToken: "publisher-one", ClaimedAt: claimAt, ClaimExpiresAt: claimAt.Add(30 * time.Second), + }) + if err != nil { + return fmt.Errorf("claim close event delivery: %w", err) + } + assert.True(t, firstClaim.Acquired) + assert.False(t, firstClaim.Delivered) + expectedUIDs := make([]string, len(closeEvents)) + for i := range closeEvents { + expectedUIDs[i] = closeEvents[i].UID + } + assert.Equal(t, expectedUIDs, firstClaim.EventUIDs) + + activeClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ + ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, + ClaimToken: "publisher-two", ClaimedAt: claimAt.Add(time.Second), + ClaimExpiresAt: claimAt.Add(31 * time.Second), + }) + if err != nil { + return fmt.Errorf("inspect active close event delivery claim: %w", err) + } + assert.False(t, activeClaim.Acquired) + assert.False(t, activeClaim.Delivered) + + recoveredClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ + ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, + ClaimToken: "publisher-two", ClaimedAt: claimAt.Add(31 * time.Second), + ClaimExpiresAt: claimAt.Add(61 * time.Second), + }) + if err != nil { + return fmt.Errorf("recover expired close event delivery claim: %w", err) + } + assert.True(t, recoveredClaim.Acquired) + err = store.CompleteCloseEventDelivery(ctx, db.CloseEventDeliveryClaimUpdateParams{ + ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, + ClaimToken: "publisher-one", At: claimAt.Add(32 * time.Second), + }) + assert.ErrorIs(t, err, db.ErrCloseEventDeliveryClaimLost) + if err := store.ReleaseCloseEventDeliveryClaim(ctx, db.CloseEventDeliveryClaimUpdateParams{ + ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, + ClaimToken: "publisher-two", At: claimAt.Add(32 * time.Second), + }); err != nil { + return fmt.Errorf("release close event delivery claim: %w", err) + } + + finalClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ + ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, + ClaimToken: "publisher-three", ClaimedAt: claimAt.Add(33 * time.Second), + ClaimExpiresAt: claimAt.Add(63 * time.Second), + }) + if err != nil { + return fmt.Errorf("claim released close event delivery: %w", err) + } + assert.True(t, finalClaim.Acquired) + if err := store.CompleteCloseEventDelivery(ctx, db.CloseEventDeliveryClaimUpdateParams{ + ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, + ClaimToken: "publisher-three", At: claimAt.Add(34 * time.Second), + }); err != nil { + return fmt.Errorf("complete close event delivery: %w", err) + } + deliveredClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ + ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, + ClaimToken: "publisher-four", ClaimedAt: claimAt.Add(35 * time.Second), + ClaimExpiresAt: claimAt.Add(65 * time.Second), + }) + if err != nil { + return fmt.Errorf("read completed close event delivery: %w", err) + } + assert.False(t, deliveredClaim.Acquired) + assert.True(t, deliveredClaim.Delivered) + assert.Equal(t, expectedUIDs, deliveredClaim.EventUIDs) projectGuardIssue, _, err := store.CreateIssue(ctx, db.CreateIssueParams{ ProjectID: project.ID, Title: "project-pinned close", Author: "conformance-agent", diff --git a/internal/db/errors.go b/internal/db/errors.go index 590578e0d..7582c5a96 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -39,6 +39,18 @@ var ( // ErrAlreadyClaimed is returned by ClaimOwner when another actor already // owns the issue and Force=false. ErrAlreadyClaimed = errors.New("already claimed") + + // ErrCloseEventDeliveryFingerprintMismatch means a caller addressed an + // existing close delivery with a different request fingerprint. + ErrCloseEventDeliveryFingerprintMismatch = errors.New("close event delivery fingerprint mismatch") + + // ErrCloseEventDeliveryClaimActive means another daemon still owns the + // bounded delivery claim. + ErrCloseEventDeliveryClaimActive = errors.New("close event delivery claim is active") + + // ErrCloseEventDeliveryClaimLost means another publisher recovered or + // completed the delivery before this claim update. + ErrCloseEventDeliveryClaimLost = errors.New("close event delivery claim lost") ) // EditIssueAtomic sentinels. diff --git a/internal/db/params.go b/internal/db/params.go index 04f2ea63a..671fd4bf6 100644 --- a/internal/db/params.go +++ b/internal/db/params.go @@ -98,6 +98,28 @@ type CloseIssueParams struct { IdempotencyFingerprint string } +// ClaimCloseEventDeliveryParams identifies one pending close-event batch and +// gives its publisher a bounded ownership window. ClaimToken distinguishes a +// stale publisher from a later daemon that recovers an expired claim. +type ClaimCloseEventDeliveryParams struct { + ProjectID int64 + IdempotencyKey string + Fingerprint string + ClaimToken string + ClaimedAt time.Time + ClaimExpiresAt time.Time +} + +// CloseEventDeliveryClaimUpdateParams completes or releases the exact claim +// acquired by ClaimCloseEventDelivery. +type CloseEventDeliveryClaimUpdateParams struct { + ProjectID int64 + IdempotencyKey string + Fingerprint string + ClaimToken string + At time.Time +} + // ListIssuesParams filters single-project list output. type ListIssuesParams struct { ProjectID int64 diff --git a/internal/db/pgstore/close_event_deliveries.go b/internal/db/pgstore/close_event_deliveries.go new file mode 100644 index 000000000..974a5b4cd --- /dev/null +++ b/internal/db/pgstore/close_event_deliveries.go @@ -0,0 +1,168 @@ +package pgstore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "go.kenn.io/kata/internal/db" +) + +func insertCloseEventDeliveryTx( + ctx context.Context, + tx *sql.Tx, + params db.CloseIssueParams, + projectID int64, + issueUID string, + events []db.Event, + at string, +) error { + if params.IdempotencyKey == "" { + return nil + } + if params.IdempotencyFingerprint == "" || len(events) == 0 { + return errors.New("record close event delivery: fingerprint and events are required") + } + eventUIDs := make([]string, len(events)) + for i := range events { + eventUIDs[i] = events[i].UID + } + encoded, err := json.Marshal(eventUIDs) + if err != nil { + return fmt.Errorf("encode close event delivery: %w", err) + } + _, err = tx.ExecContext(ctx, `INSERT INTO close_event_deliveries + (project_id, idempotency_key, issue_uid, fingerprint, event_uids, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $6)`, + projectID, params.IdempotencyKey, issueUID, + params.IdempotencyFingerprint, string(encoded), at) + if err != nil { + return fmt.Errorf("record close event delivery: %w", mapSQLError(err, nil)) + } + return nil +} + +// ClaimCloseEventDelivery gives one publisher a bounded claim on a keyed +// close's ordered event batch. +func (s *Store) ClaimCloseEventDelivery( + ctx context.Context, params db.ClaimCloseEventDeliveryParams, +) (db.CloseEventDeliveryClaim, error) { + if err := validateCloseEventDeliveryClaim(params); err != nil { + return db.CloseEventDeliveryClaim{}, err + } + var claim db.CloseEventDeliveryClaim + err := s.withSerializableTx(ctx, func(tx *sql.Tx) error { + result, err := tx.ExecContext(ctx, `UPDATE close_event_deliveries + SET state = 'delivering', claim_token = $1, claim_expires_at = $2, updated_at = $3 +WHERE project_id = $4 AND idempotency_key = $5 AND fingerprint = $6 + AND state <> 'delivered' + AND (state = 'pending' OR claim_expires_at <= $3 OR claim_token = $1)`, + params.ClaimToken, formatStoredTime(params.ClaimExpiresAt), formatStoredTime(params.ClaimedAt), + params.ProjectID, params.IdempotencyKey, params.Fingerprint) + if err != nil { + return mapSQLError(err, nil) + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + var fingerprint, encodedUIDs, state string + err = tx.QueryRowContext(ctx, `SELECT fingerprint, event_uids, state + FROM close_event_deliveries WHERE project_id = $1 AND idempotency_key = $2`, + params.ProjectID, params.IdempotencyKey).Scan(&fingerprint, &encodedUIDs, &state) + if errors.Is(err, sql.ErrNoRows) { + return db.ErrNotFound + } + if err != nil { + return mapSQLError(err, nil) + } + if fingerprint != params.Fingerprint { + return db.ErrCloseEventDeliveryFingerprintMismatch + } + if err := json.Unmarshal([]byte(encodedUIDs), &claim.EventUIDs); err != nil { + return fmt.Errorf("decode close event delivery: %w", err) + } + claim.Acquired = affected > 0 + claim.Delivered = state == "delivered" + return nil + }) + return claim, err +} + +func validateCloseEventDeliveryClaim(params db.ClaimCloseEventDeliveryParams) error { + if params.ProjectID <= 0 || params.IdempotencyKey == "" || + params.Fingerprint == "" || strings.TrimSpace(params.ClaimToken) == "" || + params.ClaimedAt.IsZero() || !params.ClaimExpiresAt.After(params.ClaimedAt) { + return errors.New("claim close event delivery: invalid parameters") + } + return nil +} + +func (s *Store) ReleaseCloseEventDeliveryClaim( + ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, +) error { + return s.updateCloseEventDeliveryClaim(ctx, params, false) +} + +func (s *Store) CompleteCloseEventDelivery( + ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, +) error { + return s.updateCloseEventDeliveryClaim(ctx, params, true) +} + +func (s *Store) updateCloseEventDeliveryClaim( + ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, complete bool, +) error { + if params.ProjectID <= 0 || params.IdempotencyKey == "" || + params.Fingerprint == "" || strings.TrimSpace(params.ClaimToken) == "" || params.At.IsZero() { + return errors.New("update close event delivery claim: invalid parameters") + } + return s.withSerializableTx(ctx, func(tx *sql.Tx) error { + state := "pending" + deliveredAt := any(nil) + if complete { + state = "delivered" + deliveredAt = formatStoredTime(params.At) + } + result, err := tx.ExecContext(ctx, `UPDATE close_event_deliveries + SET state = $1, claim_token = NULL, claim_expires_at = NULL, + delivered_at = $2, updated_at = $3 +WHERE project_id = $4 AND idempotency_key = $5 AND fingerprint = $6 + AND state = 'delivering' AND claim_token = $7`, + state, deliveredAt, formatStoredTime(params.At), params.ProjectID, + params.IdempotencyKey, params.Fingerprint, params.ClaimToken) + if err != nil { + return mapSQLError(err, nil) + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected > 0 { + return nil + } + var fingerprint, currentState string + err = tx.QueryRowContext(ctx, `SELECT fingerprint, state + FROM close_event_deliveries WHERE project_id = $1 AND idempotency_key = $2`, + params.ProjectID, params.IdempotencyKey).Scan(&fingerprint, ¤tState) + if errors.Is(err, sql.ErrNoRows) { + return db.ErrNotFound + } + if err != nil { + return mapSQLError(err, nil) + } + if fingerprint != params.Fingerprint { + return db.ErrCloseEventDeliveryFingerprintMismatch + } + if complete && currentState == "delivered" { + return nil + } + if !complete && currentState == "pending" { + return nil + } + return db.ErrCloseEventDeliveryClaimLost + }) +} diff --git a/internal/db/pgstore/foundation_test.go b/internal/db/pgstore/foundation_test.go index 9893b6a70..a5d7e6a3c 100644 --- a/internal/db/pgstore/foundation_test.go +++ b/internal/db/pgstore/foundation_test.go @@ -83,14 +83,17 @@ func TestValidationModeRequiresConfiguredSchemaOwnerBeforeConnecting(t *testing. assert.Contains(t, err.Error(), "postgres schema owner is required in validation mode") } -func TestPostgresMigrationRegistryIncludesExternalRootBridges(t *testing.T) { +func TestPostgresMigrationRegistryIncludesForwardChain(t *testing.T) { t.Parallel() migrations := pgstore.Migrations() - require.Len(t, migrations, 1) + require.Len(t, migrations, 2) assert.Equal(t, 25, migrations[0].FromVersion) assert.Equal(t, 26, migrations[0].ToVersion) assert.Equal(t, "000026_external_root_bridges.up.sql", migrations[0].Name) + assert.Equal(t, 26, migrations[1].FromVersion) + assert.Equal(t, 27, migrations[1].ToVersion) + assert.Equal(t, "000027_close_event_deliveries.up.sql", migrations[1].Name) } func TestExternalRootMigrationUpgradesVersion25(t *testing.T) { @@ -120,7 +123,7 @@ func TestExternalRootMigrationUpgradesVersion25(t *testing.T) { t.Cleanup(func() { _ = migrated.Close() }) version, err := migrated.SchemaVersion(ctx) require.NoError(t, err) - assert.Equal(t, 26, version) + assert.Equal(t, db.CurrentSchemaVersion(), version) project, err := migrated.CreateProject(ctx, "example-project") require.NoError(t, err) @@ -138,6 +141,66 @@ func TestExternalRootMigrationUpgradesVersion25(t *testing.T) { assert.Equal(t, "issue.external_root_bound", event.Type) } +func TestCloseEventDeliveryMigrationUpgradesVersion26(t *testing.T) { + if testing.Short() { + t.Skip("requires postgres testcontainer") + } + ctx := context.Background() + dsn, cleanup := testenv.NewPostgresContainer(t, ctx) + t.Cleanup(cleanup) + + admin, err := sql.Open("pgx", dsn) + require.NoError(t, err) + t.Cleanup(func() { _ = admin.Close() }) + + const schema = "close_delivery_upgrade" + store, err := pgstore.OpenWithConfig(ctx, dsn, pgstore.Config{ + Schema: schema, SchemaMode: pgstore.SchemaModeBootstrap, + }) + require.NoError(t, err) + require.NoError(t, store.Close()) + _, err = admin.ExecContext(ctx, ` +DROP TABLE close_delivery_upgrade.close_event_deliveries; +UPDATE close_delivery_upgrade.meta SET value='26' WHERE key='schema_version'`) + require.NoError(t, err) + + migrated, err := pgstore.OpenWithConfig(ctx, dsn, pgstore.Config{ + Schema: schema, SchemaMode: pgstore.SchemaModeBootstrap, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = migrated.Close() }) + version, err := migrated.SchemaVersion(ctx) + require.NoError(t, err) + assert.Equal(t, db.CurrentSchemaVersion(), version) + + project, err := migrated.CreateProject(ctx, "delivery-project") + require.NoError(t, err) + issue, _, err := migrated.CreateIssue(ctx, db.CreateIssueParams{ + ProjectID: project.ID, Title: "Recover close delivery", Author: "tester", + }) + require.NoError(t, err) + fingerprint := strings.Repeat("b", 64) + _, events, changed, err := migrated.CloseIssueGuarded(ctx, db.CloseIssueParams{ + IssueID: issue.ID, ExpectedProjectID: project.ID, Reason: "wontfix", Actor: "tester", + IdempotencyKey: "migration-close", IdempotencyFingerprint: fingerprint, + }) + require.NoError(t, err) + require.True(t, changed) + require.NotEmpty(t, events) + claimedAt := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) + claim, err := migrated.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ + ProjectID: project.ID, IdempotencyKey: "migration-close", Fingerprint: fingerprint, + ClaimToken: "migration-publisher", ClaimedAt: claimedAt, + ClaimExpiresAt: claimedAt.Add(time.Minute), + }) + require.NoError(t, err) + assert.True(t, claim.Acquired) + require.Len(t, claim.EventUIDs, len(events)) + for i := range events { + assert.Equal(t, events[i].UID, claim.EventUIDs[i]) + } +} + func TestExternalRootMigrationRollsBackSchemaAndVersionTogether(t *testing.T) { if testing.Short() { t.Skip("requires postgres testcontainer") @@ -186,10 +249,11 @@ func setExternalRootMigrationSource( ) { t.Helper() _, err := admin.ExecContext(ctx, fmt.Sprintf(` +DROP TABLE %s.close_event_deliveries; DROP TABLE %s.external_field_states; DROP TABLE %s.external_field_mappings; DROP TABLE %s.external_root_bindings; -UPDATE %s.meta SET value='25' WHERE key='schema_version'`, schema, schema, schema, schema)) // #nosec G201 -- schema is a fixed test identifier. +UPDATE %s.meta SET value='25' WHERE key='schema_version'`, schema, schema, schema, schema, schema)) // #nosec G201 -- schema is a fixed test identifier. require.NoError(t, err) } diff --git a/internal/db/pgstore/issue_lifecycle.go b/internal/db/pgstore/issue_lifecycle.go index 07373e91b..f51e128c9 100644 --- a/internal/db/pgstore/issue_lifecycle.go +++ b/internal/db/pgstore/issue_lifecycle.go @@ -362,6 +362,9 @@ func (s *Store) closeIssueWithEvents( } events = append(events, generated...) } + if err := insertCloseEventDeliveryTx(ctx, tx, p, current.ProjectID, current.UID, events, closedAt); err != nil { + return err + } issue, err = scanIssue(tx.QueryRowContext(ctx, issueSelect+` WHERE i.id = $1`, current.ID)) return err }) diff --git a/internal/db/pgstore/migrations.go b/internal/db/pgstore/migrations.go index 86d8d1e09..4ad894515 100644 --- a/internal/db/pgstore/migrations.go +++ b/internal/db/pgstore/migrations.go @@ -14,6 +14,9 @@ var vectorSchemaSQL string //go:embed migrations/000026_external_root_bridges.up.sql var externalRootBridgesMigrationSQL string +//go:embed migrations/000027_close_event_deliveries.up.sql +var closeEventDeliveriesMigrationSQL string + // Migration is one immutable Postgres schema transition. Assets form an exact // version chain; callers applying them externally must stamp ToVersion only // after SQL succeeds in the same transaction. @@ -34,6 +37,12 @@ var migrationAssets = []Migration{ Name: "000026_external_root_bridges.up.sql", SQL: externalRootBridgesMigrationSQL, }, + { + FromVersion: 26, + ToVersion: 27, + Name: "000027_close_event_deliveries.up.sql", + SQL: closeEventDeliveriesMigrationSQL, + }, } // Migrations returns forward migrations from previously released Postgres diff --git a/internal/db/pgstore/migrations/000027_close_event_deliveries.up.sql b/internal/db/pgstore/migrations/000027_close_event_deliveries.up.sql new file mode 100644 index 000000000..4cf04eefd --- /dev/null +++ b/internal/db/pgstore/migrations/000027_close_event_deliveries.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE close_event_deliveries ( + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL, + issue_uid TEXT NOT NULL CHECK (length(issue_uid) = 26), + fingerprint TEXT NOT NULL CHECK (length(fingerprint) = 64), + event_uids TEXT NOT NULL + CHECK (jsonb_typeof(event_uids::jsonb) = 'array' + AND jsonb_array_length(event_uids::jsonb) > 0), + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'delivering', 'delivered')), + claim_token TEXT, + claim_expires_at TEXT, + delivered_at TEXT, + created_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + updated_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + PRIMARY KEY(project_id, idempotency_key), + CHECK ( + (state = 'pending' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NULL) + OR (state = 'delivering' AND claim_token IS NOT NULL AND length(trim(claim_token)) > 0 AND claim_expires_at IS NOT NULL AND delivered_at IS NULL) + OR (state = 'delivered' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NOT NULL) + ) +); diff --git a/internal/db/pgstore/schema.sql b/internal/db/pgstore/schema.sql index 9a25e71ec..2f6a9516e 100644 --- a/internal/db/pgstore/schema.sql +++ b/internal/db/pgstore/schema.sql @@ -329,6 +329,29 @@ CREATE INDEX idx_events_idempotency ON events(project_id, (payload::jsonb ->> 'idempotency_key'), created_at) WHERE type = 'issue.created' AND (payload::jsonb ->> 'idempotency_key') IS NOT NULL; +CREATE TABLE close_event_deliveries ( + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL, + issue_uid TEXT NOT NULL CHECK (length(issue_uid) = 26), + fingerprint TEXT NOT NULL CHECK (length(fingerprint) = 64), + event_uids TEXT NOT NULL + CHECK (jsonb_typeof(event_uids::jsonb) = 'array' + AND jsonb_array_length(event_uids::jsonb) > 0), + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'delivering', 'delivered')), + claim_token TEXT, + claim_expires_at TEXT, + delivered_at TEXT, + created_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + updated_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), + PRIMARY KEY(project_id, idempotency_key), + CHECK ( + (state = 'pending' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NULL) + OR (state = 'delivering' AND claim_token IS NOT NULL AND length(trim(claim_token)) > 0 AND claim_expires_at IS NOT NULL AND delivered_at IS NULL) + OR (state = 'delivered' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NOT NULL) + ) +); + CREATE TABLE api_tokens ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, diff --git a/internal/db/pgstore/schema_manifest.go b/internal/db/pgstore/schema_manifest.go index 01158c3f6..7e935537a 100644 --- a/internal/db/pgstore/schema_manifest.go +++ b/internal/db/pgstore/schema_manifest.go @@ -11,9 +11,9 @@ import ( ) const ( - canonicalColumnFingerprint = "ac18204f243ce27534e2dc584392492ff0deb4f9bad259b4eb40dc2db2131bd3" - canonicalConstraintFingerprint = "622da4ed2d6779e3f627ef3e4c8287ef6d1b4876a139ca9b6f7d8fa11a8192f4" - canonicalIndexFingerprint = "e128126dddd2ce0bc37ac07a4cc98352f556f0e986c34e0e83b73d06594e848b" + canonicalColumnFingerprint = "1365bac20d9eb2c93f899bcd9f205c93d75156c2532849fb670d41b64ead47b8" + canonicalConstraintFingerprint = "ac644760305b405a899980500b20c0c7e0766a97910e88e5caf08d53353281c5" + canonicalIndexFingerprint = "dfbd0f5315a34bfbe2359d72e74d4715e0a8a87540f44a6435394488301f50af" vectorColumnFingerprint = "b8c7cb5e43f3c17502fc3e1deba77a772c3e9a486be623a96729de8866381c31" vectorConstraintFingerprint = "3a39a82331175295586fb3399dff2221fe511171f21e31a88410dd091c3a3cf4" vectorIndexFingerprint = "7868c4a815ebee6451cef203509dcedcd21401c76f49fb666e9facaad2f7aef3" @@ -25,6 +25,7 @@ const ( var canonicalTableColumns = map[string]string{ //nolint:gosec // Catalog column names, not credential values. "api_tokens": "id,token_hash,actor,name,created_at,last_used_at,revoked_at", "comments": "id,uid,issue_id,author,body,created_at", + "close_event_deliveries": "project_id,idempotency_key,issue_uid,fingerprint,event_uids,state,claim_token,claim_expires_at,delivered_at,created_at,updated_at", "events": "id,uid,origin_instance_uid,project_id,project_name,issue_id,issue_uid,related_issue_id,related_issue_uid,type,actor,payload,hlc_physical_ms,hlc_counter,content_hash,created_at", "external_field_mappings": "id,connector_instance,kata_field,external_field_id,external_field_name,accepted_kinds_json,nullable,writable,schema_revision,active,created_at,updated_at", "external_field_states": "binding_id,mapping_id,baseline_json,conflicted,conflict_kata,conflict_external,conflict_at,updated_at", @@ -65,6 +66,7 @@ uniq_one_parent_per_child idx_links_from idx_links_to idx_links_from_uid idx_lin idx_issue_labels_label idx_events_project idx_events_issue idx_events_related idx_events_issue_uid idx_events_related_issue_uid idx_events_origin_instance idx_events_origin_project_id idx_events_hlc idx_events_content_hash idx_events_idempotency +close_event_deliveries_pkey idx_purge_log_reset idx_purge_log_project_reset idx_purge_log_issue idx_purge_log_issue_uid idx_purge_log_project_uid idx_purge_log_origin_instance idx_purge_log_short_id idx_project_purge_log_reset idx_project_purge_log_project_reset idx_federation_bindings_role_enabled diff --git a/internal/db/pgstore/stubgen/main.go b/internal/db/pgstore/stubgen/main.go index 50dacd262..a6791b81d 100644 --- a/internal/db/pgstore/stubgen/main.go +++ b/internal/db/pgstore/stubgen/main.go @@ -95,6 +95,8 @@ var alreadyImplemented = map[string]bool{ "BatchProjectStats": true, // project_lifecycle.go "ChildrenOfIssue": true, // relationship_queries.go "ClaimIssueSyncBinding": true, // issue_sync.go + "ClaimCloseEventDelivery": true, // close_event_deliveries.go + "CompleteCloseEventDelivery": true, // close_event_deliveries.go "ClaimExternalRootBinding": true, // external_roots.go "ClaimOwner": true, // issue_lifecycle.go "ClaimStatus": true, // claims_core.go @@ -245,6 +247,7 @@ var alreadyImplemented = map[string]bool{ "RecordExternalRootError": true, // external_roots.go "RecordExternalRootSuccess": true, // external_roots.go "RefreshInstanceUID": true, // store.go + "ReleaseCloseEventDeliveryClaim": true, // close_event_deliveries.go "RefreshIssueSyncBinding": true, // issue_sync.go "RejectPendingClaim": true, // claims_pending.go "ReassignAlias": true, // aliases.go diff --git a/internal/db/pgstore/stubgen/main_test.go b/internal/db/pgstore/stubgen/main_test.go index 640d49bbf..ecc5f63e3 100644 --- a/internal/db/pgstore/stubgen/main_test.go +++ b/internal/db/pgstore/stubgen/main_test.go @@ -38,7 +38,7 @@ type Storage interface { Only(context.Context) error } func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { methods, err := CollectStorageMethodInventory("../../storage.go") require.NoError(t, err) - require.Len(t, methods, 249) + require.Len(t, methods, 252) var implemented []string var stubbed []string @@ -74,6 +74,7 @@ func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { "BatchProjectStats", "CheckClaimGate", "ChildrenOfIssue", + "ClaimCloseEventDelivery", "ClaimExternalRootBinding", "ClaimExternalRootBindingForManualAction", "ClaimExternalRootBindingForManualReconcile", @@ -91,6 +92,7 @@ func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { "CloseIssueWithEvents", "CommentBodyByID", "CommentsByIssue", + "CompleteCloseEventDelivery", "CountActiveFederationEnrollments", "CountLiveClaims", "CountOpenIssues", @@ -260,6 +262,7 @@ func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { "RejectPendingClaim", "RelationshipsByIssues", "ReleaseClaim", + "ReleaseCloseEventDeliveryClaim", "ReleaseExternalRootClaim", "RemoveLabel", "RemoveLabelAndEvent", diff --git a/internal/db/schema_version.go b/internal/db/schema_version.go index 6a84b8fa0..aebc7e968 100644 --- a/internal/db/schema_version.go +++ b/internal/db/schema_version.go @@ -5,7 +5,7 @@ package db // compare on-disk state against this number when opening existing ones; a // mismatch surfaces either ErrSchemaCutoverRequired (for older sources, so // storeopen can drive a JSONL cutover) or a newer-than-binary error. -const currentSchemaVersion = 26 +const currentSchemaVersion = 27 // CurrentSchemaVersion returns the schema version expected by this binary. // Backends and the cutover path read this to align freshly created databases diff --git a/internal/db/sqlitestore/close_event_deliveries.go b/internal/db/sqlitestore/close_event_deliveries.go new file mode 100644 index 000000000..c08bec5a8 --- /dev/null +++ b/internal/db/sqlitestore/close_event_deliveries.go @@ -0,0 +1,205 @@ +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "go.kenn.io/kata/internal/db" +) + +func insertCloseEventDeliveryTx( + ctx context.Context, + tx *sql.Tx, + params db.CloseIssueParams, + projectID int64, + issueUID string, + events []db.Event, + at string, +) error { + if params.IdempotencyKey == "" { + return nil + } + if params.IdempotencyFingerprint == "" || len(events) == 0 { + return errors.New("record close event delivery: fingerprint and events are required") + } + eventUIDs := make([]string, len(events)) + for i := range events { + eventUIDs[i] = events[i].UID + } + encoded, err := json.Marshal(eventUIDs) + if err != nil { + return fmt.Errorf("encode close event delivery: %w", err) + } + _, err = tx.ExecContext(ctx, `INSERT INTO close_event_deliveries + (project_id, idempotency_key, issue_uid, fingerprint, event_uids, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + projectID, params.IdempotencyKey, issueUID, + params.IdempotencyFingerprint, string(encoded), at, at) + if err != nil { + return fmt.Errorf("record close event delivery: %w", err) + } + return nil +} + +// ClaimCloseEventDelivery gives one publisher a bounded claim on a keyed +// close's ordered event batch. An expired claim may be recovered by another +// daemon; a delivered batch is returned without being claimed again. +func (d *Store) ClaimCloseEventDelivery( + ctx context.Context, params db.ClaimCloseEventDeliveryParams, +) (db.CloseEventDeliveryClaim, error) { + var claim db.CloseEventDeliveryClaim + err := d.RetryTransient(ctx, func() error { + var err error + claim, err = d.claimCloseEventDelivery(ctx, params) + return err + }) + return claim, err +} + +func (d *Store) claimCloseEventDelivery( + ctx context.Context, params db.ClaimCloseEventDeliveryParams, +) (db.CloseEventDeliveryClaim, error) { + if err := validateCloseEventDeliveryClaim(params); err != nil { + return db.CloseEventDeliveryClaim{}, err + } + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return db.CloseEventDeliveryClaim{}, fmt.Errorf("begin close event delivery claim: %w", err) + } + defer func() { _ = tx.Rollback() }() + + result, err := tx.ExecContext(ctx, `UPDATE close_event_deliveries + SET state = 'delivering', claim_token = ?, claim_expires_at = ?, updated_at = ? + WHERE project_id = ? AND idempotency_key = ? AND fingerprint = ? + AND state <> 'delivered' + AND (state = 'pending' OR claim_expires_at <= ? OR claim_token = ?)`, + params.ClaimToken, + params.ClaimExpiresAt.UTC().Format(sqliteTimeFormat), + params.ClaimedAt.UTC().Format(sqliteTimeFormat), + params.ProjectID, params.IdempotencyKey, params.Fingerprint, + params.ClaimedAt.UTC().Format(sqliteTimeFormat), params.ClaimToken) + if err != nil { + return db.CloseEventDeliveryClaim{}, fmt.Errorf("claim close event delivery: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return db.CloseEventDeliveryClaim{}, fmt.Errorf("read close event delivery claim result: %w", err) + } + + var fingerprint, encodedUIDs, state string + err = tx.QueryRowContext(ctx, `SELECT fingerprint, event_uids, state + FROM close_event_deliveries WHERE project_id = ? AND idempotency_key = ?`, + params.ProjectID, params.IdempotencyKey).Scan(&fingerprint, &encodedUIDs, &state) + if errors.Is(err, sql.ErrNoRows) { + return db.CloseEventDeliveryClaim{}, db.ErrNotFound + } + if err != nil { + return db.CloseEventDeliveryClaim{}, fmt.Errorf("read close event delivery: %w", err) + } + if fingerprint != params.Fingerprint { + return db.CloseEventDeliveryClaim{}, db.ErrCloseEventDeliveryFingerprintMismatch + } + var eventUIDs []string + if err := json.Unmarshal([]byte(encodedUIDs), &eventUIDs); err != nil { + return db.CloseEventDeliveryClaim{}, fmt.Errorf("decode close event delivery: %w", err) + } + if err := tx.Commit(); err != nil { + return db.CloseEventDeliveryClaim{}, fmt.Errorf("commit close event delivery claim: %w", err) + } + return db.CloseEventDeliveryClaim{ + EventUIDs: eventUIDs, + Acquired: affected > 0, + Delivered: state == "delivered", + }, nil +} + +func validateCloseEventDeliveryClaim(params db.ClaimCloseEventDeliveryParams) error { + if params.ProjectID <= 0 || params.IdempotencyKey == "" || + params.Fingerprint == "" || strings.TrimSpace(params.ClaimToken) == "" || + params.ClaimedAt.IsZero() || !params.ClaimExpiresAt.After(params.ClaimedAt) { + return errors.New("claim close event delivery: invalid parameters") + } + return nil +} + +// ReleaseCloseEventDeliveryClaim returns an unbroadcast batch to pending. +func (d *Store) ReleaseCloseEventDeliveryClaim( + ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, +) error { + return d.updateCloseEventDeliveryClaim(ctx, params, false) +} + +// CompleteCloseEventDelivery marks a broadcast batch delivered. Repeating a +// completion after an ambiguous commit is safe. +func (d *Store) CompleteCloseEventDelivery( + ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, +) error { + return d.updateCloseEventDeliveryClaim(ctx, params, true) +} + +func (d *Store) updateCloseEventDeliveryClaim( + ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, complete bool, +) error { + if params.ProjectID <= 0 || params.IdempotencyKey == "" || + params.Fingerprint == "" || strings.TrimSpace(params.ClaimToken) == "" || params.At.IsZero() { + return errors.New("update close event delivery claim: invalid parameters") + } + return d.RetryTransient(ctx, func() error { + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin close event delivery update: %w", err) + } + defer func() { _ = tx.Rollback() }() + + state := "pending" + deliveredAt := any(nil) + if complete { + state = "delivered" + deliveredAt = params.At.UTC().Format(sqliteTimeFormat) + } + result, err := tx.ExecContext(ctx, `UPDATE close_event_deliveries + SET state = ?, claim_token = NULL, claim_expires_at = NULL, + delivered_at = ?, updated_at = ? + WHERE project_id = ? AND idempotency_key = ? AND fingerprint = ? + AND state = 'delivering' AND claim_token = ?`, + state, deliveredAt, params.At.UTC().Format(sqliteTimeFormat), + params.ProjectID, params.IdempotencyKey, params.Fingerprint, params.ClaimToken) + if err != nil { + return fmt.Errorf("update close event delivery: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read close event delivery update result: %w", err) + } + if affected == 0 { + var fingerprint, currentState string + err := tx.QueryRowContext(ctx, `SELECT fingerprint, state + FROM close_event_deliveries WHERE project_id = ? AND idempotency_key = ?`, + params.ProjectID, params.IdempotencyKey).Scan(&fingerprint, ¤tState) + if errors.Is(err, sql.ErrNoRows) { + return db.ErrNotFound + } + if err != nil { + return fmt.Errorf("read close event delivery after claim loss: %w", err) + } + if fingerprint != params.Fingerprint { + return db.ErrCloseEventDeliveryFingerprintMismatch + } + if complete && currentState == "delivered" { + return tx.Commit() + } + if !complete && currentState == "pending" { + return tx.Commit() + } + return db.ErrCloseEventDeliveryClaimLost + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit close event delivery update: %w", err) + } + return nil + }) +} diff --git a/internal/db/sqlitestore/federation_test.go b/internal/db/sqlitestore/federation_test.go index 7bfa65699..b89baf78c 100644 --- a/internal/db/sqlitestore/federation_test.go +++ b/internal/db/sqlitestore/federation_test.go @@ -20,7 +20,7 @@ import ( func TestFederationSchemaVersionAndTable(t *testing.T) { d := openTestDB(t) - assert.Equal(t, 26, db.CurrentSchemaVersion()) + assert.Equal(t, 27, db.CurrentSchemaVersion()) assertSchemaVersion(t, d, db.CurrentSchemaVersion()) assertSchemaObject(t, d, "federation_bindings") assertSchemaObject(t, d, "idx_federation_bindings_role_enabled") diff --git a/internal/db/sqlitestore/github_sync_test.go b/internal/db/sqlitestore/github_sync_test.go index ec558f202..39f75df37 100644 --- a/internal/db/sqlitestore/github_sync_test.go +++ b/internal/db/sqlitestore/github_sync_test.go @@ -17,11 +17,11 @@ func TestGitHubSyncSchemaVersion(t *testing.T) { d := openTestDB(t) ctx := context.Background() - assert.Equal(t, 26, db.CurrentSchemaVersion()) + assert.Equal(t, 27, db.CurrentSchemaVersion()) got, err := d.SchemaVersion(ctx) require.NoError(t, err) - assert.Equal(t, 26, got) - assertSchemaVersion(t, d, 26) + assert.Equal(t, 27, got) + assertSchemaVersion(t, d, 27) } func TestGitHubSyncEnableAndReenableSameRepository(t *testing.T) { diff --git a/internal/db/sqlitestore/queries.go b/internal/db/sqlitestore/queries.go index 0347e684f..1837efbdb 100644 --- a/internal/db/sqlitestore/queries.go +++ b/internal/db/sqlitestore/queries.go @@ -1593,6 +1593,9 @@ func (d *Store) closeIssueGuarded( } events = append(events, generated...) } + if err := insertCloseEventDeliveryTx(ctx, tx, p, issue.ProjectID, issue.UID, events, closedAt); err != nil { + return db.Issue{}, nil, false, err + } updated, err := issueByIDTx(ctx, tx, p.IssueID) if err != nil { return db.Issue{}, nil, false, err diff --git a/internal/db/sqlitestore/schema.sql b/internal/db/sqlitestore/schema.sql index 5d16fc934..759cfc35f 100644 --- a/internal/db/sqlitestore/schema.sql +++ b/internal/db/sqlitestore/schema.sql @@ -219,6 +219,30 @@ CREATE INDEX idx_events_idempotency ON events(project_id, json_extract(payload, '$.idempotency_key'), created_at) WHERE type = 'issue.created' AND json_extract(payload, '$.idempotency_key') IS NOT NULL; +CREATE TABLE close_event_deliveries ( + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + idempotency_key TEXT NOT NULL, + issue_uid TEXT NOT NULL CHECK (length(issue_uid) = 26), + fingerprint TEXT NOT NULL CHECK (length(fingerprint) = 64), + event_uids TEXT NOT NULL + CHECK (json_valid(event_uids) + AND json_type(event_uids) = 'array' + AND json_array_length(event_uids) > 0), + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'delivering', 'delivered')), + claim_token TEXT, + claim_expires_at DATETIME, + delivered_at DATETIME, + created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY(project_id, idempotency_key), + CHECK ( + (state = 'pending' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NULL) + OR (state = 'delivering' AND claim_token IS NOT NULL AND length(trim(claim_token)) > 0 AND claim_expires_at IS NOT NULL AND delivered_at IS NULL) + OR (state = 'delivered' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NOT NULL) + ) +); + CREATE TABLE api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, token_hash TEXT NOT NULL UNIQUE, diff --git a/internal/db/sqlitestore/schema_completeness_test.go b/internal/db/sqlitestore/schema_completeness_test.go index db26956e9..861f28420 100644 --- a/internal/db/sqlitestore/schema_completeness_test.go +++ b/internal/db/sqlitestore/schema_completeness_test.go @@ -19,7 +19,7 @@ func TestAllSchemaTablesExist(t *testing.T) { d := openTestDB(t) wanted := []string{ "projects", "project_aliases", "issues", "comments", - "links", "issue_labels", "events", "purge_log", "project_purge_log", + "links", "issue_labels", "events", "close_event_deliveries", "purge_log", "project_purge_log", "api_tokens", "federation_bindings", "federation_sync_status", "federation_quarantine", "federation_enrollments", "issue_sync_bindings", "issue_sync_status", diff --git a/internal/db/storage.go b/internal/db/storage.go index 239b2859a..88979c3aa 100644 --- a/internal/db/storage.go +++ b/internal/db/storage.go @@ -148,6 +148,9 @@ type Storage interface { // the returned release function runs. Implementations must coordinate every // daemon that can write the same backend, not only goroutines in one server. AcquireIdempotencyLock(ctx context.Context, projectID int64, key string) (release func() error, err error) + ClaimCloseEventDelivery(ctx context.Context, p ClaimCloseEventDeliveryParams) (CloseEventDeliveryClaim, error) + ReleaseCloseEventDeliveryClaim(ctx context.Context, p CloseEventDeliveryClaimUpdateParams) error + CompleteCloseEventDelivery(ctx context.Context, p CloseEventDeliveryClaimUpdateParams) error LookupIdempotency(ctx context.Context, projectID int64, key string, since time.Time) (*IdempotencyMatch, error) LookupIssueMutationIdempotency(ctx context.Context, projectID int64, eventType, key string, since time.Time) (*IdempotencyMatch, error) // LookupCommentIdempotency scopes by issue UID when issueUID is non-empty; diff --git a/internal/db/types.go b/internal/db/types.go index 25008667c..1f64577fd 100644 --- a/internal/db/types.go +++ b/internal/db/types.go @@ -453,6 +453,15 @@ type Event struct { CreatedAt time.Time `json:"created_at"` } +// CloseEventDeliveryClaim is the durable publish state for one keyed close. +// EventUIDs preserve the transaction's event order while the immutable events +// table remains the source of each event's contents. +type CloseEventDeliveryClaim struct { + EventUIDs []string + Acquired bool + Delivered bool +} + // RemoteEvent is the portable event shape accepted from a federation hub. // Backend-local row IDs and display-only short IDs are intentionally excluded. type RemoteEvent struct { From a2ad38cc6b0002944a7239a8c78c233ac46fa670 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 20:55:24 -0500 Subject: [PATCH 12/14] Replay keyed closes from the events table instead of a delivery table A keyed close retry only needs the committed issue.closed receipt, which the events table already stores under the idempotency key. Schema version 27 and the close_event_deliveries table existed to re-broadcast the event batch after a daemon crashed between commit and publish. That gap exists for every mutation today because the hook queue is in-memory, and SSE clients already recover missed events through Last-Event-ID, so a per-mutation delivery table and claim lease bought little for its cost. The table also carried two defects: a key reused after the seven-day lookup window collided with its permanent primary key and returned a 500, and the PostgreSQL table registry never listed it, so split-role privilege validation skipped it. The ambiguous-commit case still publishes exactly once. Both stores return the attempted event batch alongside a commit error, and the handler publishes that batch only when the receipt it finds is the same event this attempt wrote. Replayed receipts now also check the caller's host scope against the issue's current project, matching the comment replay path, so a moved issue cannot leak state through the old route. Generated with Claude Code Co-authored-by: Claude Fable 5.1 --- docs/operations/postgres.md | 7 - internal/daemon/handlers_actions.go | 108 ++------- .../daemon/handlers_actions_retry_test.go | 99 ++------- internal/db/dbtest/conformance.go | 2 +- internal/db/dbtest/conformance_core.go | 75 ------- internal/db/errors.go | 11 - internal/db/params.go | 22 -- internal/db/pgstore/close_event_deliveries.go | 168 -------------- internal/db/pgstore/foundation_test.go | 72 +----- internal/db/pgstore/issue_lifecycle.go | 3 - internal/db/pgstore/migrations.go | 9 - .../000027_close_event_deliveries.up.sql | 22 -- internal/db/pgstore/schema.sql | 23 -- internal/db/pgstore/schema_manifest.go | 8 +- internal/db/pgstore/stubgen/main.go | 3 - internal/db/pgstore/stubgen/main_test.go | 5 +- internal/db/schema_version.go | 2 +- .../db/sqlitestore/close_event_deliveries.go | 205 ------------------ internal/db/sqlitestore/federation_test.go | 2 +- internal/db/sqlitestore/github_sync_test.go | 6 +- internal/db/sqlitestore/queries.go | 3 - internal/db/sqlitestore/schema.sql | 24 -- .../sqlitestore/schema_completeness_test.go | 2 +- internal/db/storage.go | 3 - internal/db/types.go | 9 - 25 files changed, 50 insertions(+), 843 deletions(-) delete mode 100644 internal/db/pgstore/close_event_deliveries.go delete mode 100644 internal/db/pgstore/migrations/000027_close_event_deliveries.up.sql delete mode 100644 internal/db/sqlitestore/close_event_deliveries.go diff --git a/docs/operations/postgres.md b/docs/operations/postgres.md index 39a1cf494..2d93f0f1f 100644 --- a/docs/operations/postgres.md +++ b/docs/operations/postgres.md @@ -215,13 +215,6 @@ attacker. It is not the production private-network posture. Until a release explicitly documents an online migration, treat every schema upgrade as an offline operation: -Schema version 27 adds `close_event_deliveries`, which keeps the ordered event -identities and publish claim for retry-safe keyed closes. Upgrade it with the -schema-owner role while all daemons are stopped. The runtime role needs the -standard table grants described above before daemons restart. A version 26 -binary cannot open the upgraded schema. Rollback requires the pre-upgrade -snapshot and the matching version 26 binary. - 1. Stop every daemon or scale all replicas to zero. The migration advisory lock serializes migrators; it does not quiesce ordinary writes from an old binary. 2. Take a database-native snapshot and a JSONL export. Keep the native snapshot diff --git a/internal/daemon/handlers_actions.go b/internal/daemon/handlers_actions.go index 8d1f54b7f..f18c84087 100644 --- a/internal/daemon/handlers_actions.go +++ b/internal/daemon/handlers_actions.go @@ -13,7 +13,6 @@ import ( "go.kenn.io/kata/internal/api" "go.kenn.io/kata/internal/db" - katauid "go.kenn.io/kata/internal/uid" ) // registerActionsHandlers installs POST /actions/close and /actions/reopen. @@ -66,6 +65,9 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { tuiBypass := tuiBypassAllowed(ctx, in.Body.Source, in.Body.Reason) idempotencyFingerprint := "" if in.IdempotencyKey != "" { + if _, err := activeProjectByID(ctx, cfg.DB, in.ProjectID); err != nil { + return nil, err + } release, err := cfg.DB.AcquireIdempotencyLock(ctx, in.ProjectID, in.IdempotencyKey) if err != nil { return nil, internalAPIError(err) @@ -79,21 +81,10 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { idempotencyFingerprint = closeIdempotencyFingerprint( match.IssueUID, in.Ref, actor, in.Body.Reason, in.Body.Message, in.Body.Source, in.Body.Evidence, in.Body.DryRun, ifMatchRev) - if match.Fingerprint == idempotencyFingerprint { - if err := publishCloseEventDelivery( - ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint, - ); err != nil { - return nil, err - } - } return closeIdempotencyResponse(ctx, cfg, match, idempotencyFingerprint) } } - includeDeleted := db.IncludeDeletedNo - if in.IdempotencyKey != "" { - includeDeleted = db.IncludeDeletedYes - } - issue, err := activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, includeDeleted) + issue, err := activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, db.IncludeDeletedNo) if err != nil { return nil, err } @@ -102,9 +93,6 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { issue.UID, in.Ref, actor, in.Body.Reason, in.Body.Message, in.Body.Source, in.Body.Evidence, in.Body.DryRun, ifMatchRev) } - if issue.DeletedAt != nil { - return nil, api.NewError(404, "issue_not_found", "issue not found", "", nil) - } if ifMatchRev != nil && issue.Revision != *ifMatchRev { return nil, api.NewError(412, "revision_conflict", fmt.Sprintf("issue revision is %d", issue.Revision), "", nil) @@ -207,28 +195,25 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { } return err }) - if err == nil && changed && in.IdempotencyKey == "" { + if err == nil && changed { cfg.Publish().Events(in.ProjectID, events) } - if in.IdempotencyKey != "" { - if recoveryErr := publishCloseEventDelivery( - ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint, - ); recoveryErr != nil { - return nil, recoveryErr - } - } if err != nil { // A connection can fail after the database commits. The keyed - // receipt proves whether this attempt landed. When it did, publish - // the complete event batch returned by that attempt before replying. + // receipt proves whether this attempt landed. When it did, the + // batch this attempt returned is the committed batch, so publish + // it exactly once before replying with the receipt. if in.IdempotencyKey != "" { - reuse, lookupErr := tryCloseIdempotencyMatch( - ctx, cfg, in.ProjectID, in.IdempotencyKey, idempotencyFingerprint) + match, lookupErr := lookupCloseIdempotencyMatch( + ctx, cfg, in.ProjectID, in.IdempotencyKey) if lookupErr != nil { return nil, lookupErr } - if reuse != nil { - return reuse, nil + if match != nil { + if len(events) > 0 && events[0].UID == match.Event.UID { + cfg.Publish().Events(in.ProjectID, events) + } + return closeIdempotencyResponse(ctx, cfg, match, idempotencyFingerprint) } } if revisionConflict, ok := errors.AsType[*db.RevisionConflictError](err); ok { @@ -327,63 +312,6 @@ func registerActionsHandlers(humaAPI huma.API, cfg ServerConfig) { }) } -const closeEventDeliveryClaimLease = 30 * time.Second - -func publishCloseEventDelivery( - ctx context.Context, cfg ServerConfig, projectID int64, key, fingerprint string, -) error { - claimToken, err := katauid.New() - if err != nil { - return internalAPIError(fmt.Errorf("generate close event delivery claim: %w", err)) - } - claimedAt := time.Now().UTC() - claim, err := cfg.DB.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ - ProjectID: projectID, IdempotencyKey: key, Fingerprint: fingerprint, - ClaimToken: claimToken, ClaimedAt: claimedAt, - ClaimExpiresAt: claimedAt.Add(closeEventDeliveryClaimLease), - }) - if errors.Is(err, db.ErrNotFound) { - // Receipts created before durable delivery tracking have no batch row. - return nil - } - if err != nil { - return internalAPIError(err) - } - if claim.Delivered { - return nil - } - if !claim.Acquired { - return internalAPIError(db.ErrCloseEventDeliveryClaimActive) - } - releaseClaim := func(cause error) error { - releaseErr := cfg.DB.ReleaseCloseEventDeliveryClaim(ctx, db.CloseEventDeliveryClaimUpdateParams{ - ProjectID: projectID, IdempotencyKey: key, Fingerprint: fingerprint, - ClaimToken: claimToken, At: time.Now().UTC(), - }) - return internalAPIError(errors.Join(cause, releaseErr)) - } - stored, err := cfg.DB.EventsByUIDs(ctx, projectID, claim.EventUIDs) - if err != nil { - return releaseClaim(err) - } - if len(stored) != len(claim.EventUIDs) { - return releaseClaim(errors.New("stored close event batch is incomplete")) - } - for i := range stored { - if stored[i].UID != claim.EventUIDs[i] { - return releaseClaim(errors.New("stored close event batch is out of order")) - } - } - cfg.Publish().Events(projectID, stored) - if err := cfg.DB.CompleteCloseEventDelivery(ctx, db.CloseEventDeliveryClaimUpdateParams{ - ProjectID: projectID, IdempotencyKey: key, Fingerprint: fingerprint, - ClaimToken: claimToken, At: time.Now().UTC(), - }); err != nil { - return internalAPIError(err) - } - return nil -} - func tryCloseIdempotencyMatch( ctx context.Context, cfg ServerConfig, @@ -423,6 +351,12 @@ func closeIdempotencyResponse( if err != nil { return nil, internalAPIError(err) } + // The issue may have moved since the original close. Replaying the + // receipt exposes its current state, so the caller's host scope must + // cover the project it lives in now. + if _, err := authorizeHostProjectScope(ctx, []int64{current.ProjectID}, nil, false); err != nil { + return nil, err + } original := match.Event out := &api.MutationResponse{} out.Body.Issue = current diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go index 9a9de92f2..60515184a 100644 --- a/internal/daemon/handlers_actions_retry_test.go +++ b/internal/daemon/handlers_actions_retry_test.go @@ -64,11 +64,10 @@ func (s *closeRaceStore) CloseIssueGuarded( type lostCloseResponseStore struct { db.Storage - failNext bool - failEventLookupOnce bool - failIssueReadOnce bool - committed bool - committedEvents []db.Event + failNext bool + failIssueReadOnce bool + committed bool + committedEvents []db.Event } func (s *lostCloseResponseStore) CloseIssueGuarded( @@ -84,16 +83,6 @@ func (s *lostCloseResponseStore) CloseIssueGuarded( return issue, events, changed, err } -func (s *lostCloseResponseStore) EventsByUIDs( - ctx context.Context, projectID int64, uids []string, -) ([]db.Event, error) { - if s.committed && s.failEventLookupOnce { - s.failEventLookupOnce = false - return nil, errors.New("event lookup unavailable") - } - return s.Storage.EventsByUIDs(ctx, projectID, uids) -} - func (s *lostCloseResponseStore) IssueByID(ctx context.Context, id int64) (db.Issue, error) { if s.committed && s.failIssueReadOnce { s.failIssueReadOnce = false @@ -365,7 +354,7 @@ func TestClose_RecoversCommittedReceiptAndPublishesEventsOnce(t *testing.T) { assert.Empty(t, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) } -func TestClose_RetryRecoversUndeliveredCommitWithoutDuplicateEvents(t *testing.T) { +func TestClose_LostResponsePublishesOnceWhenReceiptReadFails(t *testing.T) { database := openTestDB(t) project, issue := createClaimHubIssueInDB(t, database.db) _, err := database.db.AcquireClaim(t.Context(), db.AcquireClaimParams{ @@ -377,8 +366,7 @@ func TestClose_RetryRecoversUndeliveredCommitWithoutDuplicateEvents(t *testing.T }) require.NoError(t, err) store := &lostCloseResponseStore{ - Storage: database.db, failNext: true, - failEventLookupOnce: true, failIssueReadOnce: true, + Storage: database.db, failNext: true, failIssueReadOnce: true, } sink := &recordingSink{} broadcaster := daemon.NewEventBroadcaster() @@ -399,77 +387,18 @@ func TestClose_RetryRecoversUndeliveredCommitWithoutDuplicateEvents(t *testing.T first := postWithHeader(t, ts, path, headers, body) assertAPIError(t, first.status, first.body, http.StatusInternalServerError, "internal") require.Len(t, store.committedEvents, 2) - assert.Empty(t, sink.snapshot()) - assert.Empty(t, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) - - second := postWithHeader(t, ts, path, headers, body) - assertAPIError(t, second.status, second.body, http.StatusInternalServerError, "internal") - assert.Equal(t, store.committedEvents, sink.snapshot()) + assert.Equal(t, store.committedEvents, sink.snapshot(), + "the committed batch is published even when the receipt response fails") assert.Equal(t, []int64{store.committedEvents[0].ID, store.committedEvents[1].ID}, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) - third := postWithHeader(t, ts, path, headers, body) - requireOK(t, third) - assert.Equal(t, store.committedEvents, sink.snapshot()) - assert.Empty(t, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) -} - -func TestClose_FreshServerRecoversUndeliveredCommitWithoutDuplicateEvents(t *testing.T) { - database := openTestDB(t) - project, issue := createClaimHubIssueInDB(t, database.db) - _, err := database.db.AcquireClaim(t.Context(), db.AcquireClaimParams{ - ProjectID: project.ID, IssueRef: issue.ShortID, - Principal: db.ClaimPrincipal{ - HolderInstanceUID: database.db.InstanceUID(), Holder: "agent-one", ClientKind: "cli", - }, - ClaimKind: "hard", Now: time.Now().UTC(), - }) - require.NoError(t, err) - store := &lostCloseResponseStore{ - Storage: database.db, failNext: true, - failEventLookupOnce: true, - } - firstSink := &recordingSink{} - firstBroadcaster := daemon.NewEventBroadcaster() - firstSubscription := firstBroadcaster.Subscribe(daemon.SubFilter{ProjectID: project.ID}) - defer firstSubscription.Unsub() - firstServer := startTestServer(t, daemon.ServerConfig{ - DB: store, StartedAt: database.now, Hooks: firstSink, Broadcaster: firstBroadcaster, - }) - path := issueURLRef(project.ID, issue.ShortID, "actions/close") - headers := map[string]string{"Idempotency-Key": "close-fresh-server-retry-1"} - body := map[string]any{ - "actor": "agent-one", - "reason": "wontfix", - "message": "Reviewed the request and recorded why the work should stop here.", - "retry_protocol": "close-v1", - } - - first := postWithHeader(t, firstServer, path, headers, body) - assertAPIError(t, first.status, first.body, http.StatusInternalServerError, "internal") - require.Len(t, store.committedEvents, 2) - assert.Empty(t, firstSink.snapshot()) - assert.Empty(t, drainBroadcastIDs(t, firstSubscription.Ch, 50*time.Millisecond)) - firstServer.Close() - - secondSink := &recordingSink{} - secondBroadcaster := daemon.NewEventBroadcaster() - secondSubscription := secondBroadcaster.Subscribe(daemon.SubFilter{ProjectID: project.ID}) - defer secondSubscription.Unsub() - secondServer := startTestServer(t, daemon.ServerConfig{ - DB: database.db, StartedAt: database.now, Hooks: secondSink, Broadcaster: secondBroadcaster, - }) - - second := postWithHeader(t, secondServer, path, headers, body) + second := postWithHeader(t, ts, path, headers, body) requireOK(t, second) - assert.Equal(t, store.committedEvents, secondSink.snapshot()) - assert.Equal(t, []int64{store.committedEvents[0].ID, store.committedEvents[1].ID}, - drainBroadcastIDs(t, secondSubscription.Ch, 50*time.Millisecond)) - - third := postWithHeader(t, secondServer, path, headers, body) - requireOK(t, third) - assert.Equal(t, store.committedEvents, secondSink.snapshot()) - assert.Empty(t, drainBroadcastIDs(t, secondSubscription.Ch, 50*time.Millisecond)) + var secondOut api.MutationResponse + require.NoError(t, json.Unmarshal(second.body, &secondOut.Body)) + assert.True(t, secondOut.Body.Reused) + assert.Equal(t, store.committedEvents, sink.snapshot(), "a replay must not publish twice") + assert.Empty(t, drainBroadcastIDs(t, subscription.Ch, 50*time.Millisecond)) } func TestClose_IdempotencyRejectsDifferentRequest(t *testing.T) { diff --git a/internal/db/dbtest/conformance.go b/internal/db/dbtest/conformance.go index 8383226d0..615077dfb 100644 --- a/internal/db/dbtest/conformance.go +++ b/internal/db/dbtest/conformance.go @@ -71,7 +71,7 @@ var storageScenarios = []scenario{ }, { name: "idempotency", - methods: []string{"AcquireIdempotencyLock", "ClaimCloseEventDelivery", "CompleteCloseEventDelivery", "CreateComment", "CreateIssue", "CreateProject", "LookupCommentIdempotency", "LookupIdempotency", "LookupIssueMutationIdempotency", "ReleaseCloseEventDeliveryClaim"}, + methods: []string{"AcquireIdempotencyLock", "CreateComment", "CreateIssue", "CreateProject", "LookupCommentIdempotency", "LookupIdempotency", "LookupIssueMutationIdempotency"}, run: checkIdempotency, }, { diff --git a/internal/db/dbtest/conformance_core.go b/internal/db/dbtest/conformance_core.go index 323833daa..659dcc364 100644 --- a/internal/db/dbtest/conformance_core.go +++ b/internal/db/dbtest/conformance_core.go @@ -374,81 +374,6 @@ func checkIdempotency(t *testing.T, store db.Storage) error { assert.Equal(t, closeEvents[0].UID, closeMatch.Event.UID) assert.Equal(t, closeFingerprint, closeMatch.Fingerprint) - claimAt := time.Date(2026, time.September, 2, 12, 0, 0, 0, time.UTC) - firstClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ - ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, - ClaimToken: "publisher-one", ClaimedAt: claimAt, ClaimExpiresAt: claimAt.Add(30 * time.Second), - }) - if err != nil { - return fmt.Errorf("claim close event delivery: %w", err) - } - assert.True(t, firstClaim.Acquired) - assert.False(t, firstClaim.Delivered) - expectedUIDs := make([]string, len(closeEvents)) - for i := range closeEvents { - expectedUIDs[i] = closeEvents[i].UID - } - assert.Equal(t, expectedUIDs, firstClaim.EventUIDs) - - activeClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ - ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, - ClaimToken: "publisher-two", ClaimedAt: claimAt.Add(time.Second), - ClaimExpiresAt: claimAt.Add(31 * time.Second), - }) - if err != nil { - return fmt.Errorf("inspect active close event delivery claim: %w", err) - } - assert.False(t, activeClaim.Acquired) - assert.False(t, activeClaim.Delivered) - - recoveredClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ - ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, - ClaimToken: "publisher-two", ClaimedAt: claimAt.Add(31 * time.Second), - ClaimExpiresAt: claimAt.Add(61 * time.Second), - }) - if err != nil { - return fmt.Errorf("recover expired close event delivery claim: %w", err) - } - assert.True(t, recoveredClaim.Acquired) - err = store.CompleteCloseEventDelivery(ctx, db.CloseEventDeliveryClaimUpdateParams{ - ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, - ClaimToken: "publisher-one", At: claimAt.Add(32 * time.Second), - }) - assert.ErrorIs(t, err, db.ErrCloseEventDeliveryClaimLost) - if err := store.ReleaseCloseEventDeliveryClaim(ctx, db.CloseEventDeliveryClaimUpdateParams{ - ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, - ClaimToken: "publisher-two", At: claimAt.Add(32 * time.Second), - }); err != nil { - return fmt.Errorf("release close event delivery claim: %w", err) - } - - finalClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ - ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, - ClaimToken: "publisher-three", ClaimedAt: claimAt.Add(33 * time.Second), - ClaimExpiresAt: claimAt.Add(63 * time.Second), - }) - if err != nil { - return fmt.Errorf("claim released close event delivery: %w", err) - } - assert.True(t, finalClaim.Acquired) - if err := store.CompleteCloseEventDelivery(ctx, db.CloseEventDeliveryClaimUpdateParams{ - ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, - ClaimToken: "publisher-three", At: claimAt.Add(34 * time.Second), - }); err != nil { - return fmt.Errorf("complete close event delivery: %w", err) - } - deliveredClaim, err := store.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ - ProjectID: project.ID, IdempotencyKey: "close-request-1", Fingerprint: closeFingerprint, - ClaimToken: "publisher-four", ClaimedAt: claimAt.Add(35 * time.Second), - ClaimExpiresAt: claimAt.Add(65 * time.Second), - }) - if err != nil { - return fmt.Errorf("read completed close event delivery: %w", err) - } - assert.False(t, deliveredClaim.Acquired) - assert.True(t, deliveredClaim.Delivered) - assert.Equal(t, expectedUIDs, deliveredClaim.EventUIDs) - projectGuardIssue, _, err := store.CreateIssue(ctx, db.CreateIssueParams{ ProjectID: project.ID, Title: "project-pinned close", Author: "conformance-agent", }) diff --git a/internal/db/errors.go b/internal/db/errors.go index 68d029d05..658f5c20d 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -39,17 +39,6 @@ var ( // ErrAlreadyClaimed is returned by ClaimOwner when another actor already // owns the issue and Force=false. ErrAlreadyClaimed = errors.New("already claimed") - // ErrCloseEventDeliveryFingerprintMismatch means a caller addressed an - // existing close delivery with a different request fingerprint. - ErrCloseEventDeliveryFingerprintMismatch = errors.New("close event delivery fingerprint mismatch") - - // ErrCloseEventDeliveryClaimActive means another daemon still owns the - // bounded delivery claim. - ErrCloseEventDeliveryClaimActive = errors.New("close event delivery claim is active") - - // ErrCloseEventDeliveryClaimLost means another publisher recovered or - // completed the delivery before this claim update. - ErrCloseEventDeliveryClaimLost = errors.New("close event delivery claim lost") // ErrOwnerMismatch is returned by guarded unassign when the current owner // does not match the caller's expected owner. diff --git a/internal/db/params.go b/internal/db/params.go index 671fd4bf6..04f2ea63a 100644 --- a/internal/db/params.go +++ b/internal/db/params.go @@ -98,28 +98,6 @@ type CloseIssueParams struct { IdempotencyFingerprint string } -// ClaimCloseEventDeliveryParams identifies one pending close-event batch and -// gives its publisher a bounded ownership window. ClaimToken distinguishes a -// stale publisher from a later daemon that recovers an expired claim. -type ClaimCloseEventDeliveryParams struct { - ProjectID int64 - IdempotencyKey string - Fingerprint string - ClaimToken string - ClaimedAt time.Time - ClaimExpiresAt time.Time -} - -// CloseEventDeliveryClaimUpdateParams completes or releases the exact claim -// acquired by ClaimCloseEventDelivery. -type CloseEventDeliveryClaimUpdateParams struct { - ProjectID int64 - IdempotencyKey string - Fingerprint string - ClaimToken string - At time.Time -} - // ListIssuesParams filters single-project list output. type ListIssuesParams struct { ProjectID int64 diff --git a/internal/db/pgstore/close_event_deliveries.go b/internal/db/pgstore/close_event_deliveries.go deleted file mode 100644 index 974a5b4cd..000000000 --- a/internal/db/pgstore/close_event_deliveries.go +++ /dev/null @@ -1,168 +0,0 @@ -package pgstore - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - - "go.kenn.io/kata/internal/db" -) - -func insertCloseEventDeliveryTx( - ctx context.Context, - tx *sql.Tx, - params db.CloseIssueParams, - projectID int64, - issueUID string, - events []db.Event, - at string, -) error { - if params.IdempotencyKey == "" { - return nil - } - if params.IdempotencyFingerprint == "" || len(events) == 0 { - return errors.New("record close event delivery: fingerprint and events are required") - } - eventUIDs := make([]string, len(events)) - for i := range events { - eventUIDs[i] = events[i].UID - } - encoded, err := json.Marshal(eventUIDs) - if err != nil { - return fmt.Errorf("encode close event delivery: %w", err) - } - _, err = tx.ExecContext(ctx, `INSERT INTO close_event_deliveries - (project_id, idempotency_key, issue_uid, fingerprint, event_uids, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $6)`, - projectID, params.IdempotencyKey, issueUID, - params.IdempotencyFingerprint, string(encoded), at) - if err != nil { - return fmt.Errorf("record close event delivery: %w", mapSQLError(err, nil)) - } - return nil -} - -// ClaimCloseEventDelivery gives one publisher a bounded claim on a keyed -// close's ordered event batch. -func (s *Store) ClaimCloseEventDelivery( - ctx context.Context, params db.ClaimCloseEventDeliveryParams, -) (db.CloseEventDeliveryClaim, error) { - if err := validateCloseEventDeliveryClaim(params); err != nil { - return db.CloseEventDeliveryClaim{}, err - } - var claim db.CloseEventDeliveryClaim - err := s.withSerializableTx(ctx, func(tx *sql.Tx) error { - result, err := tx.ExecContext(ctx, `UPDATE close_event_deliveries - SET state = 'delivering', claim_token = $1, claim_expires_at = $2, updated_at = $3 -WHERE project_id = $4 AND idempotency_key = $5 AND fingerprint = $6 - AND state <> 'delivered' - AND (state = 'pending' OR claim_expires_at <= $3 OR claim_token = $1)`, - params.ClaimToken, formatStoredTime(params.ClaimExpiresAt), formatStoredTime(params.ClaimedAt), - params.ProjectID, params.IdempotencyKey, params.Fingerprint) - if err != nil { - return mapSQLError(err, nil) - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - var fingerprint, encodedUIDs, state string - err = tx.QueryRowContext(ctx, `SELECT fingerprint, event_uids, state - FROM close_event_deliveries WHERE project_id = $1 AND idempotency_key = $2`, - params.ProjectID, params.IdempotencyKey).Scan(&fingerprint, &encodedUIDs, &state) - if errors.Is(err, sql.ErrNoRows) { - return db.ErrNotFound - } - if err != nil { - return mapSQLError(err, nil) - } - if fingerprint != params.Fingerprint { - return db.ErrCloseEventDeliveryFingerprintMismatch - } - if err := json.Unmarshal([]byte(encodedUIDs), &claim.EventUIDs); err != nil { - return fmt.Errorf("decode close event delivery: %w", err) - } - claim.Acquired = affected > 0 - claim.Delivered = state == "delivered" - return nil - }) - return claim, err -} - -func validateCloseEventDeliveryClaim(params db.ClaimCloseEventDeliveryParams) error { - if params.ProjectID <= 0 || params.IdempotencyKey == "" || - params.Fingerprint == "" || strings.TrimSpace(params.ClaimToken) == "" || - params.ClaimedAt.IsZero() || !params.ClaimExpiresAt.After(params.ClaimedAt) { - return errors.New("claim close event delivery: invalid parameters") - } - return nil -} - -func (s *Store) ReleaseCloseEventDeliveryClaim( - ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, -) error { - return s.updateCloseEventDeliveryClaim(ctx, params, false) -} - -func (s *Store) CompleteCloseEventDelivery( - ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, -) error { - return s.updateCloseEventDeliveryClaim(ctx, params, true) -} - -func (s *Store) updateCloseEventDeliveryClaim( - ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, complete bool, -) error { - if params.ProjectID <= 0 || params.IdempotencyKey == "" || - params.Fingerprint == "" || strings.TrimSpace(params.ClaimToken) == "" || params.At.IsZero() { - return errors.New("update close event delivery claim: invalid parameters") - } - return s.withSerializableTx(ctx, func(tx *sql.Tx) error { - state := "pending" - deliveredAt := any(nil) - if complete { - state = "delivered" - deliveredAt = formatStoredTime(params.At) - } - result, err := tx.ExecContext(ctx, `UPDATE close_event_deliveries - SET state = $1, claim_token = NULL, claim_expires_at = NULL, - delivered_at = $2, updated_at = $3 -WHERE project_id = $4 AND idempotency_key = $5 AND fingerprint = $6 - AND state = 'delivering' AND claim_token = $7`, - state, deliveredAt, formatStoredTime(params.At), params.ProjectID, - params.IdempotencyKey, params.Fingerprint, params.ClaimToken) - if err != nil { - return mapSQLError(err, nil) - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - if affected > 0 { - return nil - } - var fingerprint, currentState string - err = tx.QueryRowContext(ctx, `SELECT fingerprint, state - FROM close_event_deliveries WHERE project_id = $1 AND idempotency_key = $2`, - params.ProjectID, params.IdempotencyKey).Scan(&fingerprint, ¤tState) - if errors.Is(err, sql.ErrNoRows) { - return db.ErrNotFound - } - if err != nil { - return mapSQLError(err, nil) - } - if fingerprint != params.Fingerprint { - return db.ErrCloseEventDeliveryFingerprintMismatch - } - if complete && currentState == "delivered" { - return nil - } - if !complete && currentState == "pending" { - return nil - } - return db.ErrCloseEventDeliveryClaimLost - }) -} diff --git a/internal/db/pgstore/foundation_test.go b/internal/db/pgstore/foundation_test.go index a5d7e6a3c..9893b6a70 100644 --- a/internal/db/pgstore/foundation_test.go +++ b/internal/db/pgstore/foundation_test.go @@ -83,17 +83,14 @@ func TestValidationModeRequiresConfiguredSchemaOwnerBeforeConnecting(t *testing. assert.Contains(t, err.Error(), "postgres schema owner is required in validation mode") } -func TestPostgresMigrationRegistryIncludesForwardChain(t *testing.T) { +func TestPostgresMigrationRegistryIncludesExternalRootBridges(t *testing.T) { t.Parallel() migrations := pgstore.Migrations() - require.Len(t, migrations, 2) + require.Len(t, migrations, 1) assert.Equal(t, 25, migrations[0].FromVersion) assert.Equal(t, 26, migrations[0].ToVersion) assert.Equal(t, "000026_external_root_bridges.up.sql", migrations[0].Name) - assert.Equal(t, 26, migrations[1].FromVersion) - assert.Equal(t, 27, migrations[1].ToVersion) - assert.Equal(t, "000027_close_event_deliveries.up.sql", migrations[1].Name) } func TestExternalRootMigrationUpgradesVersion25(t *testing.T) { @@ -123,7 +120,7 @@ func TestExternalRootMigrationUpgradesVersion25(t *testing.T) { t.Cleanup(func() { _ = migrated.Close() }) version, err := migrated.SchemaVersion(ctx) require.NoError(t, err) - assert.Equal(t, db.CurrentSchemaVersion(), version) + assert.Equal(t, 26, version) project, err := migrated.CreateProject(ctx, "example-project") require.NoError(t, err) @@ -141,66 +138,6 @@ func TestExternalRootMigrationUpgradesVersion25(t *testing.T) { assert.Equal(t, "issue.external_root_bound", event.Type) } -func TestCloseEventDeliveryMigrationUpgradesVersion26(t *testing.T) { - if testing.Short() { - t.Skip("requires postgres testcontainer") - } - ctx := context.Background() - dsn, cleanup := testenv.NewPostgresContainer(t, ctx) - t.Cleanup(cleanup) - - admin, err := sql.Open("pgx", dsn) - require.NoError(t, err) - t.Cleanup(func() { _ = admin.Close() }) - - const schema = "close_delivery_upgrade" - store, err := pgstore.OpenWithConfig(ctx, dsn, pgstore.Config{ - Schema: schema, SchemaMode: pgstore.SchemaModeBootstrap, - }) - require.NoError(t, err) - require.NoError(t, store.Close()) - _, err = admin.ExecContext(ctx, ` -DROP TABLE close_delivery_upgrade.close_event_deliveries; -UPDATE close_delivery_upgrade.meta SET value='26' WHERE key='schema_version'`) - require.NoError(t, err) - - migrated, err := pgstore.OpenWithConfig(ctx, dsn, pgstore.Config{ - Schema: schema, SchemaMode: pgstore.SchemaModeBootstrap, - }) - require.NoError(t, err) - t.Cleanup(func() { _ = migrated.Close() }) - version, err := migrated.SchemaVersion(ctx) - require.NoError(t, err) - assert.Equal(t, db.CurrentSchemaVersion(), version) - - project, err := migrated.CreateProject(ctx, "delivery-project") - require.NoError(t, err) - issue, _, err := migrated.CreateIssue(ctx, db.CreateIssueParams{ - ProjectID: project.ID, Title: "Recover close delivery", Author: "tester", - }) - require.NoError(t, err) - fingerprint := strings.Repeat("b", 64) - _, events, changed, err := migrated.CloseIssueGuarded(ctx, db.CloseIssueParams{ - IssueID: issue.ID, ExpectedProjectID: project.ID, Reason: "wontfix", Actor: "tester", - IdempotencyKey: "migration-close", IdempotencyFingerprint: fingerprint, - }) - require.NoError(t, err) - require.True(t, changed) - require.NotEmpty(t, events) - claimedAt := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) - claim, err := migrated.ClaimCloseEventDelivery(ctx, db.ClaimCloseEventDeliveryParams{ - ProjectID: project.ID, IdempotencyKey: "migration-close", Fingerprint: fingerprint, - ClaimToken: "migration-publisher", ClaimedAt: claimedAt, - ClaimExpiresAt: claimedAt.Add(time.Minute), - }) - require.NoError(t, err) - assert.True(t, claim.Acquired) - require.Len(t, claim.EventUIDs, len(events)) - for i := range events { - assert.Equal(t, events[i].UID, claim.EventUIDs[i]) - } -} - func TestExternalRootMigrationRollsBackSchemaAndVersionTogether(t *testing.T) { if testing.Short() { t.Skip("requires postgres testcontainer") @@ -249,11 +186,10 @@ func setExternalRootMigrationSource( ) { t.Helper() _, err := admin.ExecContext(ctx, fmt.Sprintf(` -DROP TABLE %s.close_event_deliveries; DROP TABLE %s.external_field_states; DROP TABLE %s.external_field_mappings; DROP TABLE %s.external_root_bindings; -UPDATE %s.meta SET value='25' WHERE key='schema_version'`, schema, schema, schema, schema, schema)) // #nosec G201 -- schema is a fixed test identifier. +UPDATE %s.meta SET value='25' WHERE key='schema_version'`, schema, schema, schema, schema)) // #nosec G201 -- schema is a fixed test identifier. require.NoError(t, err) } diff --git a/internal/db/pgstore/issue_lifecycle.go b/internal/db/pgstore/issue_lifecycle.go index 591f0a010..8f061a583 100644 --- a/internal/db/pgstore/issue_lifecycle.go +++ b/internal/db/pgstore/issue_lifecycle.go @@ -390,9 +390,6 @@ func (s *Store) closeIssueWithEvents( } events = append(events, generated...) } - if err := insertCloseEventDeliveryTx(ctx, tx, p, current.ProjectID, current.UID, events, closedAt); err != nil { - return err - } issue, err = scanIssue(tx.QueryRowContext(ctx, issueSelect+` WHERE i.id = $1`, current.ID)) return err }) diff --git a/internal/db/pgstore/migrations.go b/internal/db/pgstore/migrations.go index 4ad894515..86d8d1e09 100644 --- a/internal/db/pgstore/migrations.go +++ b/internal/db/pgstore/migrations.go @@ -14,9 +14,6 @@ var vectorSchemaSQL string //go:embed migrations/000026_external_root_bridges.up.sql var externalRootBridgesMigrationSQL string -//go:embed migrations/000027_close_event_deliveries.up.sql -var closeEventDeliveriesMigrationSQL string - // Migration is one immutable Postgres schema transition. Assets form an exact // version chain; callers applying them externally must stamp ToVersion only // after SQL succeeds in the same transaction. @@ -37,12 +34,6 @@ var migrationAssets = []Migration{ Name: "000026_external_root_bridges.up.sql", SQL: externalRootBridgesMigrationSQL, }, - { - FromVersion: 26, - ToVersion: 27, - Name: "000027_close_event_deliveries.up.sql", - SQL: closeEventDeliveriesMigrationSQL, - }, } // Migrations returns forward migrations from previously released Postgres diff --git a/internal/db/pgstore/migrations/000027_close_event_deliveries.up.sql b/internal/db/pgstore/migrations/000027_close_event_deliveries.up.sql deleted file mode 100644 index 4cf04eefd..000000000 --- a/internal/db/pgstore/migrations/000027_close_event_deliveries.up.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE close_event_deliveries ( - project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - idempotency_key TEXT NOT NULL, - issue_uid TEXT NOT NULL CHECK (length(issue_uid) = 26), - fingerprint TEXT NOT NULL CHECK (length(fingerprint) = 64), - event_uids TEXT NOT NULL - CHECK (jsonb_typeof(event_uids::jsonb) = 'array' - AND jsonb_array_length(event_uids::jsonb) > 0), - state TEXT NOT NULL DEFAULT 'pending' - CHECK (state IN ('pending', 'delivering', 'delivered')), - claim_token TEXT, - claim_expires_at TEXT, - delivered_at TEXT, - created_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), - updated_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), - PRIMARY KEY(project_id, idempotency_key), - CHECK ( - (state = 'pending' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NULL) - OR (state = 'delivering' AND claim_token IS NOT NULL AND length(trim(claim_token)) > 0 AND claim_expires_at IS NOT NULL AND delivered_at IS NULL) - OR (state = 'delivered' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NOT NULL) - ) -); diff --git a/internal/db/pgstore/schema.sql b/internal/db/pgstore/schema.sql index 2f6a9516e..9a25e71ec 100644 --- a/internal/db/pgstore/schema.sql +++ b/internal/db/pgstore/schema.sql @@ -329,29 +329,6 @@ CREATE INDEX idx_events_idempotency ON events(project_id, (payload::jsonb ->> 'idempotency_key'), created_at) WHERE type = 'issue.created' AND (payload::jsonb ->> 'idempotency_key') IS NOT NULL; -CREATE TABLE close_event_deliveries ( - project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - idempotency_key TEXT NOT NULL, - issue_uid TEXT NOT NULL CHECK (length(issue_uid) = 26), - fingerprint TEXT NOT NULL CHECK (length(fingerprint) = 64), - event_uids TEXT NOT NULL - CHECK (jsonb_typeof(event_uids::jsonb) = 'array' - AND jsonb_array_length(event_uids::jsonb) > 0), - state TEXT NOT NULL DEFAULT 'pending' - CHECK (state IN ('pending', 'delivering', 'delivered')), - claim_token TEXT, - claim_expires_at TEXT, - delivered_at TEXT, - created_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), - updated_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), - PRIMARY KEY(project_id, idempotency_key), - CHECK ( - (state = 'pending' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NULL) - OR (state = 'delivering' AND claim_token IS NOT NULL AND length(trim(claim_token)) > 0 AND claim_expires_at IS NOT NULL AND delivered_at IS NULL) - OR (state = 'delivered' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NOT NULL) - ) -); - CREATE TABLE api_tokens ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, diff --git a/internal/db/pgstore/schema_manifest.go b/internal/db/pgstore/schema_manifest.go index 7e935537a..01158c3f6 100644 --- a/internal/db/pgstore/schema_manifest.go +++ b/internal/db/pgstore/schema_manifest.go @@ -11,9 +11,9 @@ import ( ) const ( - canonicalColumnFingerprint = "1365bac20d9eb2c93f899bcd9f205c93d75156c2532849fb670d41b64ead47b8" - canonicalConstraintFingerprint = "ac644760305b405a899980500b20c0c7e0766a97910e88e5caf08d53353281c5" - canonicalIndexFingerprint = "dfbd0f5315a34bfbe2359d72e74d4715e0a8a87540f44a6435394488301f50af" + canonicalColumnFingerprint = "ac18204f243ce27534e2dc584392492ff0deb4f9bad259b4eb40dc2db2131bd3" + canonicalConstraintFingerprint = "622da4ed2d6779e3f627ef3e4c8287ef6d1b4876a139ca9b6f7d8fa11a8192f4" + canonicalIndexFingerprint = "e128126dddd2ce0bc37ac07a4cc98352f556f0e986c34e0e83b73d06594e848b" vectorColumnFingerprint = "b8c7cb5e43f3c17502fc3e1deba77a772c3e9a486be623a96729de8866381c31" vectorConstraintFingerprint = "3a39a82331175295586fb3399dff2221fe511171f21e31a88410dd091c3a3cf4" vectorIndexFingerprint = "7868c4a815ebee6451cef203509dcedcd21401c76f49fb666e9facaad2f7aef3" @@ -25,7 +25,6 @@ const ( var canonicalTableColumns = map[string]string{ //nolint:gosec // Catalog column names, not credential values. "api_tokens": "id,token_hash,actor,name,created_at,last_used_at,revoked_at", "comments": "id,uid,issue_id,author,body,created_at", - "close_event_deliveries": "project_id,idempotency_key,issue_uid,fingerprint,event_uids,state,claim_token,claim_expires_at,delivered_at,created_at,updated_at", "events": "id,uid,origin_instance_uid,project_id,project_name,issue_id,issue_uid,related_issue_id,related_issue_uid,type,actor,payload,hlc_physical_ms,hlc_counter,content_hash,created_at", "external_field_mappings": "id,connector_instance,kata_field,external_field_id,external_field_name,accepted_kinds_json,nullable,writable,schema_revision,active,created_at,updated_at", "external_field_states": "binding_id,mapping_id,baseline_json,conflicted,conflict_kata,conflict_external,conflict_at,updated_at", @@ -66,7 +65,6 @@ uniq_one_parent_per_child idx_links_from idx_links_to idx_links_from_uid idx_lin idx_issue_labels_label idx_events_project idx_events_issue idx_events_related idx_events_issue_uid idx_events_related_issue_uid idx_events_origin_instance idx_events_origin_project_id idx_events_hlc idx_events_content_hash idx_events_idempotency -close_event_deliveries_pkey idx_purge_log_reset idx_purge_log_project_reset idx_purge_log_issue idx_purge_log_issue_uid idx_purge_log_project_uid idx_purge_log_origin_instance idx_purge_log_short_id idx_project_purge_log_reset idx_project_purge_log_project_reset idx_federation_bindings_role_enabled diff --git a/internal/db/pgstore/stubgen/main.go b/internal/db/pgstore/stubgen/main.go index d74022f74..6d9999db3 100644 --- a/internal/db/pgstore/stubgen/main.go +++ b/internal/db/pgstore/stubgen/main.go @@ -95,8 +95,6 @@ var alreadyImplemented = map[string]bool{ "BatchProjectStats": true, // project_lifecycle.go "ChildrenOfIssue": true, // relationship_queries.go "ClaimIssueSyncBinding": true, // issue_sync.go - "ClaimCloseEventDelivery": true, // close_event_deliveries.go - "CompleteCloseEventDelivery": true, // close_event_deliveries.go "ClaimExternalRootBinding": true, // external_roots.go "ClaimOwner": true, // issue_lifecycle.go "ClaimOwnerIfUnowned": true, // issue_lifecycle.go @@ -248,7 +246,6 @@ var alreadyImplemented = map[string]bool{ "RecordExternalRootError": true, // external_roots.go "RecordExternalRootSuccess": true, // external_roots.go "RefreshInstanceUID": true, // store.go - "ReleaseCloseEventDeliveryClaim": true, // close_event_deliveries.go "RefreshIssueSyncBinding": true, // issue_sync.go "RejectPendingClaim": true, // claims_pending.go "ReassignAlias": true, // aliases.go diff --git a/internal/db/pgstore/stubgen/main_test.go b/internal/db/pgstore/stubgen/main_test.go index c4efa963d..5aa3c4edf 100644 --- a/internal/db/pgstore/stubgen/main_test.go +++ b/internal/db/pgstore/stubgen/main_test.go @@ -38,7 +38,7 @@ type Storage interface { Only(context.Context) error } func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { methods, err := CollectStorageMethodInventory("../../storage.go") require.NoError(t, err) - require.Len(t, methods, 254) + require.Len(t, methods, 251) var implemented []string var stubbed []string @@ -74,7 +74,6 @@ func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { "BatchProjectStats", "CheckClaimGate", "ChildrenOfIssue", - "ClaimCloseEventDelivery", "ClaimExternalRootBinding", "ClaimExternalRootBindingForManualAction", "ClaimExternalRootBindingForManualReconcile", @@ -93,7 +92,6 @@ func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { "CloseIssueWithEvents", "CommentBodyByID", "CommentsByIssue", - "CompleteCloseEventDelivery", "CountActiveFederationEnrollments", "CountLiveClaims", "CountOpenIssues", @@ -263,7 +261,6 @@ func TestStorageMethodInventoryClassifiesEveryMethod(t *testing.T) { "RejectPendingClaim", "RelationshipsByIssues", "ReleaseClaim", - "ReleaseCloseEventDeliveryClaim", "ReleaseExternalRootClaim", "RemoveLabel", "RemoveLabelAndEvent", diff --git a/internal/db/schema_version.go b/internal/db/schema_version.go index aebc7e968..6a84b8fa0 100644 --- a/internal/db/schema_version.go +++ b/internal/db/schema_version.go @@ -5,7 +5,7 @@ package db // compare on-disk state against this number when opening existing ones; a // mismatch surfaces either ErrSchemaCutoverRequired (for older sources, so // storeopen can drive a JSONL cutover) or a newer-than-binary error. -const currentSchemaVersion = 27 +const currentSchemaVersion = 26 // CurrentSchemaVersion returns the schema version expected by this binary. // Backends and the cutover path read this to align freshly created databases diff --git a/internal/db/sqlitestore/close_event_deliveries.go b/internal/db/sqlitestore/close_event_deliveries.go deleted file mode 100644 index c08bec5a8..000000000 --- a/internal/db/sqlitestore/close_event_deliveries.go +++ /dev/null @@ -1,205 +0,0 @@ -package sqlitestore - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - - "go.kenn.io/kata/internal/db" -) - -func insertCloseEventDeliveryTx( - ctx context.Context, - tx *sql.Tx, - params db.CloseIssueParams, - projectID int64, - issueUID string, - events []db.Event, - at string, -) error { - if params.IdempotencyKey == "" { - return nil - } - if params.IdempotencyFingerprint == "" || len(events) == 0 { - return errors.New("record close event delivery: fingerprint and events are required") - } - eventUIDs := make([]string, len(events)) - for i := range events { - eventUIDs[i] = events[i].UID - } - encoded, err := json.Marshal(eventUIDs) - if err != nil { - return fmt.Errorf("encode close event delivery: %w", err) - } - _, err = tx.ExecContext(ctx, `INSERT INTO close_event_deliveries - (project_id, idempotency_key, issue_uid, fingerprint, event_uids, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - projectID, params.IdempotencyKey, issueUID, - params.IdempotencyFingerprint, string(encoded), at, at) - if err != nil { - return fmt.Errorf("record close event delivery: %w", err) - } - return nil -} - -// ClaimCloseEventDelivery gives one publisher a bounded claim on a keyed -// close's ordered event batch. An expired claim may be recovered by another -// daemon; a delivered batch is returned without being claimed again. -func (d *Store) ClaimCloseEventDelivery( - ctx context.Context, params db.ClaimCloseEventDeliveryParams, -) (db.CloseEventDeliveryClaim, error) { - var claim db.CloseEventDeliveryClaim - err := d.RetryTransient(ctx, func() error { - var err error - claim, err = d.claimCloseEventDelivery(ctx, params) - return err - }) - return claim, err -} - -func (d *Store) claimCloseEventDelivery( - ctx context.Context, params db.ClaimCloseEventDeliveryParams, -) (db.CloseEventDeliveryClaim, error) { - if err := validateCloseEventDeliveryClaim(params); err != nil { - return db.CloseEventDeliveryClaim{}, err - } - tx, err := d.BeginTx(ctx, nil) - if err != nil { - return db.CloseEventDeliveryClaim{}, fmt.Errorf("begin close event delivery claim: %w", err) - } - defer func() { _ = tx.Rollback() }() - - result, err := tx.ExecContext(ctx, `UPDATE close_event_deliveries - SET state = 'delivering', claim_token = ?, claim_expires_at = ?, updated_at = ? - WHERE project_id = ? AND idempotency_key = ? AND fingerprint = ? - AND state <> 'delivered' - AND (state = 'pending' OR claim_expires_at <= ? OR claim_token = ?)`, - params.ClaimToken, - params.ClaimExpiresAt.UTC().Format(sqliteTimeFormat), - params.ClaimedAt.UTC().Format(sqliteTimeFormat), - params.ProjectID, params.IdempotencyKey, params.Fingerprint, - params.ClaimedAt.UTC().Format(sqliteTimeFormat), params.ClaimToken) - if err != nil { - return db.CloseEventDeliveryClaim{}, fmt.Errorf("claim close event delivery: %w", err) - } - affected, err := result.RowsAffected() - if err != nil { - return db.CloseEventDeliveryClaim{}, fmt.Errorf("read close event delivery claim result: %w", err) - } - - var fingerprint, encodedUIDs, state string - err = tx.QueryRowContext(ctx, `SELECT fingerprint, event_uids, state - FROM close_event_deliveries WHERE project_id = ? AND idempotency_key = ?`, - params.ProjectID, params.IdempotencyKey).Scan(&fingerprint, &encodedUIDs, &state) - if errors.Is(err, sql.ErrNoRows) { - return db.CloseEventDeliveryClaim{}, db.ErrNotFound - } - if err != nil { - return db.CloseEventDeliveryClaim{}, fmt.Errorf("read close event delivery: %w", err) - } - if fingerprint != params.Fingerprint { - return db.CloseEventDeliveryClaim{}, db.ErrCloseEventDeliveryFingerprintMismatch - } - var eventUIDs []string - if err := json.Unmarshal([]byte(encodedUIDs), &eventUIDs); err != nil { - return db.CloseEventDeliveryClaim{}, fmt.Errorf("decode close event delivery: %w", err) - } - if err := tx.Commit(); err != nil { - return db.CloseEventDeliveryClaim{}, fmt.Errorf("commit close event delivery claim: %w", err) - } - return db.CloseEventDeliveryClaim{ - EventUIDs: eventUIDs, - Acquired: affected > 0, - Delivered: state == "delivered", - }, nil -} - -func validateCloseEventDeliveryClaim(params db.ClaimCloseEventDeliveryParams) error { - if params.ProjectID <= 0 || params.IdempotencyKey == "" || - params.Fingerprint == "" || strings.TrimSpace(params.ClaimToken) == "" || - params.ClaimedAt.IsZero() || !params.ClaimExpiresAt.After(params.ClaimedAt) { - return errors.New("claim close event delivery: invalid parameters") - } - return nil -} - -// ReleaseCloseEventDeliveryClaim returns an unbroadcast batch to pending. -func (d *Store) ReleaseCloseEventDeliveryClaim( - ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, -) error { - return d.updateCloseEventDeliveryClaim(ctx, params, false) -} - -// CompleteCloseEventDelivery marks a broadcast batch delivered. Repeating a -// completion after an ambiguous commit is safe. -func (d *Store) CompleteCloseEventDelivery( - ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, -) error { - return d.updateCloseEventDeliveryClaim(ctx, params, true) -} - -func (d *Store) updateCloseEventDeliveryClaim( - ctx context.Context, params db.CloseEventDeliveryClaimUpdateParams, complete bool, -) error { - if params.ProjectID <= 0 || params.IdempotencyKey == "" || - params.Fingerprint == "" || strings.TrimSpace(params.ClaimToken) == "" || params.At.IsZero() { - return errors.New("update close event delivery claim: invalid parameters") - } - return d.RetryTransient(ctx, func() error { - tx, err := d.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin close event delivery update: %w", err) - } - defer func() { _ = tx.Rollback() }() - - state := "pending" - deliveredAt := any(nil) - if complete { - state = "delivered" - deliveredAt = params.At.UTC().Format(sqliteTimeFormat) - } - result, err := tx.ExecContext(ctx, `UPDATE close_event_deliveries - SET state = ?, claim_token = NULL, claim_expires_at = NULL, - delivered_at = ?, updated_at = ? - WHERE project_id = ? AND idempotency_key = ? AND fingerprint = ? - AND state = 'delivering' AND claim_token = ?`, - state, deliveredAt, params.At.UTC().Format(sqliteTimeFormat), - params.ProjectID, params.IdempotencyKey, params.Fingerprint, params.ClaimToken) - if err != nil { - return fmt.Errorf("update close event delivery: %w", err) - } - affected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("read close event delivery update result: %w", err) - } - if affected == 0 { - var fingerprint, currentState string - err := tx.QueryRowContext(ctx, `SELECT fingerprint, state - FROM close_event_deliveries WHERE project_id = ? AND idempotency_key = ?`, - params.ProjectID, params.IdempotencyKey).Scan(&fingerprint, ¤tState) - if errors.Is(err, sql.ErrNoRows) { - return db.ErrNotFound - } - if err != nil { - return fmt.Errorf("read close event delivery after claim loss: %w", err) - } - if fingerprint != params.Fingerprint { - return db.ErrCloseEventDeliveryFingerprintMismatch - } - if complete && currentState == "delivered" { - return tx.Commit() - } - if !complete && currentState == "pending" { - return tx.Commit() - } - return db.ErrCloseEventDeliveryClaimLost - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit close event delivery update: %w", err) - } - return nil - }) -} diff --git a/internal/db/sqlitestore/federation_test.go b/internal/db/sqlitestore/federation_test.go index b89baf78c..7bfa65699 100644 --- a/internal/db/sqlitestore/federation_test.go +++ b/internal/db/sqlitestore/federation_test.go @@ -20,7 +20,7 @@ import ( func TestFederationSchemaVersionAndTable(t *testing.T) { d := openTestDB(t) - assert.Equal(t, 27, db.CurrentSchemaVersion()) + assert.Equal(t, 26, db.CurrentSchemaVersion()) assertSchemaVersion(t, d, db.CurrentSchemaVersion()) assertSchemaObject(t, d, "federation_bindings") assertSchemaObject(t, d, "idx_federation_bindings_role_enabled") diff --git a/internal/db/sqlitestore/github_sync_test.go b/internal/db/sqlitestore/github_sync_test.go index 39f75df37..ec558f202 100644 --- a/internal/db/sqlitestore/github_sync_test.go +++ b/internal/db/sqlitestore/github_sync_test.go @@ -17,11 +17,11 @@ func TestGitHubSyncSchemaVersion(t *testing.T) { d := openTestDB(t) ctx := context.Background() - assert.Equal(t, 27, db.CurrentSchemaVersion()) + assert.Equal(t, 26, db.CurrentSchemaVersion()) got, err := d.SchemaVersion(ctx) require.NoError(t, err) - assert.Equal(t, 27, got) - assertSchemaVersion(t, d, 27) + assert.Equal(t, 26, got) + assertSchemaVersion(t, d, 26) } func TestGitHubSyncEnableAndReenableSameRepository(t *testing.T) { diff --git a/internal/db/sqlitestore/queries.go b/internal/db/sqlitestore/queries.go index aadc8ad7b..d726e0706 100644 --- a/internal/db/sqlitestore/queries.go +++ b/internal/db/sqlitestore/queries.go @@ -1593,9 +1593,6 @@ func (d *Store) closeIssueGuarded( } events = append(events, generated...) } - if err := insertCloseEventDeliveryTx(ctx, tx, p, issue.ProjectID, issue.UID, events, closedAt); err != nil { - return db.Issue{}, nil, false, err - } updated, err := issueByIDTx(ctx, tx, p.IssueID) if err != nil { return db.Issue{}, nil, false, err diff --git a/internal/db/sqlitestore/schema.sql b/internal/db/sqlitestore/schema.sql index 759cfc35f..5d16fc934 100644 --- a/internal/db/sqlitestore/schema.sql +++ b/internal/db/sqlitestore/schema.sql @@ -219,30 +219,6 @@ CREATE INDEX idx_events_idempotency ON events(project_id, json_extract(payload, '$.idempotency_key'), created_at) WHERE type = 'issue.created' AND json_extract(payload, '$.idempotency_key') IS NOT NULL; -CREATE TABLE close_event_deliveries ( - project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - idempotency_key TEXT NOT NULL, - issue_uid TEXT NOT NULL CHECK (length(issue_uid) = 26), - fingerprint TEXT NOT NULL CHECK (length(fingerprint) = 64), - event_uids TEXT NOT NULL - CHECK (json_valid(event_uids) - AND json_type(event_uids) = 'array' - AND json_array_length(event_uids) > 0), - state TEXT NOT NULL DEFAULT 'pending' - CHECK (state IN ('pending', 'delivering', 'delivered')), - claim_token TEXT, - claim_expires_at DATETIME, - delivered_at DATETIME, - created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - PRIMARY KEY(project_id, idempotency_key), - CHECK ( - (state = 'pending' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NULL) - OR (state = 'delivering' AND claim_token IS NOT NULL AND length(trim(claim_token)) > 0 AND claim_expires_at IS NOT NULL AND delivered_at IS NULL) - OR (state = 'delivered' AND claim_token IS NULL AND claim_expires_at IS NULL AND delivered_at IS NOT NULL) - ) -); - CREATE TABLE api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, token_hash TEXT NOT NULL UNIQUE, diff --git a/internal/db/sqlitestore/schema_completeness_test.go b/internal/db/sqlitestore/schema_completeness_test.go index 861f28420..db26956e9 100644 --- a/internal/db/sqlitestore/schema_completeness_test.go +++ b/internal/db/sqlitestore/schema_completeness_test.go @@ -19,7 +19,7 @@ func TestAllSchemaTablesExist(t *testing.T) { d := openTestDB(t) wanted := []string{ "projects", "project_aliases", "issues", "comments", - "links", "issue_labels", "events", "close_event_deliveries", "purge_log", "project_purge_log", + "links", "issue_labels", "events", "purge_log", "project_purge_log", "api_tokens", "federation_bindings", "federation_sync_status", "federation_quarantine", "federation_enrollments", "issue_sync_bindings", "issue_sync_status", diff --git a/internal/db/storage.go b/internal/db/storage.go index 31110ee6e..e4fdb2351 100644 --- a/internal/db/storage.go +++ b/internal/db/storage.go @@ -150,9 +150,6 @@ type Storage interface { // the returned release function runs. Implementations must coordinate every // daemon that can write the same backend, not only goroutines in one server. AcquireIdempotencyLock(ctx context.Context, projectID int64, key string) (release func() error, err error) - ClaimCloseEventDelivery(ctx context.Context, p ClaimCloseEventDeliveryParams) (CloseEventDeliveryClaim, error) - ReleaseCloseEventDeliveryClaim(ctx context.Context, p CloseEventDeliveryClaimUpdateParams) error - CompleteCloseEventDelivery(ctx context.Context, p CloseEventDeliveryClaimUpdateParams) error LookupIdempotency(ctx context.Context, projectID int64, key string, since time.Time) (*IdempotencyMatch, error) LookupIssueMutationIdempotency(ctx context.Context, projectID int64, eventType, key string, since time.Time) (*IdempotencyMatch, error) // LookupCommentIdempotency scopes by issue UID when issueUID is non-empty; diff --git a/internal/db/types.go b/internal/db/types.go index 1f64577fd..25008667c 100644 --- a/internal/db/types.go +++ b/internal/db/types.go @@ -453,15 +453,6 @@ type Event struct { CreatedAt time.Time `json:"created_at"` } -// CloseEventDeliveryClaim is the durable publish state for one keyed close. -// EventUIDs preserve the transaction's event order while the immutable events -// table remains the source of each event's contents. -type CloseEventDeliveryClaim struct { - EventUIDs []string - Acquired bool - Delivered bool -} - // RemoteEvent is the portable event shape accepted from a federation hub. // Backend-local row IDs and display-only short IDs are intentionally excluded. type RemoteEvent struct { From 56e029992b3c8df572b10c791d418d56317c5aea Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 21:37:46 -0500 Subject: [PATCH 13/14] Scope comment retry keys to the issue and guard replayed receipts A comment idempotency key now identifies one request to one issue. Before this, a short-id request looked the key up by project while a ULID request looked it up by issue, so the same key sent to a second issue in the same project with the same body replayed the first issue's comment instead of posting one, and the two ref forms took different locks for the same retry. Every keyed comment now resolves to an issue UID first and locks, looks up, and fingerprints under that UID. A retry with a full ULID still survives a project move because it needs no route-scoped resolution. Replaying a close or comment receipt exposes the issue's current state, so the replay paths now apply the same visibility rules as a fresh request: the route must be the project the receipt was written in or the project the issue lives in now, that current project must not be archived, and it must be inside the caller's host scope. Without this, a receipt could be read through an unrelated project route or after its project was archived. Generated with Claude Code Co-authored-by: Claude Fable 5.1 --- internal/daemon/handlers_actions.go | 7 +- .../daemon/handlers_actions_retry_test.go | 29 +++++ internal/daemon/handlers_comments.go | 104 +++++++++++------- internal/daemon/handlers_comments_test.go | 72 ++++++++++++ internal/db/dbtest/conformance_core.go | 11 +- internal/db/pgstore/idempotency.go | 16 +-- .../db/sqlitestore/queries_idempotency.go | 27 +---- internal/db/storage.go | 7 +- 8 files changed, 185 insertions(+), 88 deletions(-) diff --git a/internal/daemon/handlers_actions.go b/internal/daemon/handlers_actions.go index f18c84087..6d580ce96 100644 --- a/internal/daemon/handlers_actions.go +++ b/internal/daemon/handlers_actions.go @@ -352,8 +352,11 @@ func closeIdempotencyResponse( return nil, internalAPIError(err) } // The issue may have moved since the original close. Replaying the - // receipt exposes its current state, so the caller's host scope must - // cover the project it lives in now. + // receipt exposes its current state, so the project it lives in now must + // be active and inside the caller's host scope. + if _, err := activeProjectByID(ctx, cfg.DB, current.ProjectID); err != nil { + return nil, err + } if _, err := authorizeHostProjectScope(ctx, []int64{current.ProjectID}, nil, false); err != nil { return nil, err } diff --git a/internal/daemon/handlers_actions_retry_test.go b/internal/daemon/handlers_actions_retry_test.go index 60515184a..9d2d9e410 100644 --- a/internal/daemon/handlers_actions_retry_test.go +++ b/internal/daemon/handlers_actions_retry_test.go @@ -515,3 +515,32 @@ func TestClose_IfMatchRejectsStaleRevisionAfterAnotherClose(t *testing.T) { assertAPIError(t, response.status, response.body, http.StatusPreconditionFailed, "revision_conflict") } + +func TestClose_ReplayRejectsArchivedCurrentProject(t *testing.T) { + h, ts, projectID, issueID := bootstrapProjectWithIssue(t) + issue, err := h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + path := issueURLRef(projectID, issue.UID, "actions/close") + headers := map[string]string{"Idempotency-Key": "close-then-archive"} + body := map[string]any{ + "actor": "agent-one", + "reason": "wontfix", + "message": "Reviewed the request and recorded why the work should stop here.", + "retry_protocol": "close-v1", + } + requireOK(t, postWithHeader(t, ts, path, headers, body)) + + target, err := h.DB().CreateProject(t.Context(), "archived-target") + require.NoError(t, err) + issue, err = h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + _, err = h.DB().MoveIssueProject(t.Context(), db.MoveIssueProjectIn{ + IssueID: issue.ID, FromProjectID: projectID, ToProjectID: target.ID, + IfMatchRev: issue.Revision, Actor: "coordinator", + }) + require.NoError(t, err) + archiveProject(t, h, target.ID, true) + + retry := postWithHeader(t, ts, path, headers, body) + assertAPIError(t, retry.status, retry.body, http.StatusNotFound, "project_not_found") +} diff --git a/internal/daemon/handlers_comments.go b/internal/daemon/handlers_comments.go index ea6509beb..13a4daee5 100644 --- a/internal/daemon/handlers_comments.go +++ b/internal/daemon/handlers_comments.go @@ -29,59 +29,50 @@ func registerCommentsHandlers(humaAPI huma.API, cfg ServerConfig) { if err != nil { return nil, err } - lookupIssueUID := "" - if uid.Valid(in.Ref) { - lookupIssueUID = strings.ToUpper(in.Ref) - } + var issue db.Issue + resolved := false fingerprint := "" if in.IdempotencyKey != "" { - lockProjectID, lockKey := in.ProjectID, in.IdempotencyKey - if lookupIssueUID != "" { - // Project IDs change when an issue moves. Zero is outside the - // persisted project ID range and gives UID-addressed retries one - // stable, backend-wide lock scope. - lockProjectID = 0 - lockKey = lookupIssueUID + "\x00" + in.IdempotencyKey + if _, err := activeProjectByID(ctx, cfg.DB, in.ProjectID); err != nil { + return nil, err + } + // Comment keys are scoped to one issue UID. A full ULID ref needs + // no resolution, so its retry survives a project move; any other + // ref form resolves inside the route project first. + issueUID := "" + if uid.Valid(in.Ref) { + issueUID = strings.ToUpper(in.Ref) + } else { + issue, err = activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, db.IncludeDeletedNo) + if err != nil { + return nil, err + } + resolved = true + issueUID = issue.UID } - release, err := cfg.DB.AcquireIdempotencyLock(ctx, lockProjectID, lockKey) + // Project IDs change when an issue moves. Zero is outside the + // persisted project ID range and gives every keyed comment one + // stable, backend-wide lock scope. + release, err := cfg.DB.AcquireIdempotencyLock(ctx, 0, issueUID+"\x00"+in.IdempotencyKey) if err != nil { return nil, internalAPIError(err) } defer func() { _ = release() }() match, err := cfg.DB.LookupCommentIdempotency( - ctx, in.ProjectID, lookupIssueUID, in.IdempotencyKey, - time.Now().Add(-idempotencyWindow)) + ctx, issueUID, in.IdempotencyKey, time.Now().Add(-idempotencyWindow)) if err != nil { return nil, internalAPIError(err) } if match != nil { - fingerprint = commentIdempotencyFingerprint(match.IssueUID, actor, in.Body.Body) - if match.Fingerprint != fingerprint { - return nil, api.NewError(409, "idempotency_mismatch", - "idempotency key matched a prior comment with a different fingerprint", - "use a fresh key or send the exact original comment", nil) - } - updated, err := cfg.DB.IssueByID(ctx, match.Comment.IssueID) - if err != nil { - return nil, internalAPIError(err) - } - if _, err := authorizeHostProjectScope( - ctx, []int64{updated.ProjectID}, nil, false, - ); err != nil { - return nil, err - } - out := &api.CommentResponse{} - out.Body.Issue = updated - out.Body.Comment = match.Comment - out.Body.Event = nil - out.Body.Changed = false - return out, nil + return replayComment(ctx, cfg, in.ProjectID, match, actor, in.Body.Body) } } - issue, err := activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, db.IncludeDeletedNo) - if err != nil { - return nil, err + if !resolved { + issue, err = activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, db.IncludeDeletedNo) + if err != nil { + return nil, err + } } if in.IdempotencyKey != "" { fingerprint = commentIdempotencyFingerprint(issue.UID, actor, in.Body.Body) @@ -167,6 +158,43 @@ func registerCommentsHandlers(humaAPI huma.API, cfg ServerConfig) { }) } +// replayComment returns a committed comment receipt to an exact retry. The +// route must be the project the comment was written in or the project the +// issue lives in now, and that current project must still be active and +// inside the caller's host scope, because the reply exposes current state. +func replayComment( + ctx context.Context, + cfg ServerConfig, + routeProjectID int64, + match *db.CommentIdempotencyMatch, + actor, body string, +) (*api.CommentResponse, error) { + if match.Fingerprint != commentIdempotencyFingerprint(match.IssueUID, actor, body) { + return nil, api.NewError(409, "idempotency_mismatch", + "idempotency key matched a prior comment with a different fingerprint", + "use a fresh key or send the exact original comment", nil) + } + current, err := cfg.DB.IssueByID(ctx, match.Comment.IssueID) + if err != nil { + return nil, internalAPIError(err) + } + if routeProjectID != match.Event.ProjectID && routeProjectID != current.ProjectID { + return nil, api.NewError(404, "issue_not_found", "issue not found", "", nil) + } + if _, err := activeProjectByID(ctx, cfg.DB, current.ProjectID); err != nil { + return nil, err + } + if _, err := authorizeHostProjectScope(ctx, []int64{current.ProjectID}, nil, false); err != nil { + return nil, err + } + out := &api.CommentResponse{} + out.Body.Issue = current + out.Body.Comment = match.Comment + out.Body.Event = nil + out.Body.Changed = false + return out, nil +} + func commentIdempotencyFingerprint(issueUID, actor, body string) string { encoded, _ := json.Marshal(struct { IssueUID string `json:"issue_uid"` diff --git a/internal/daemon/handlers_comments_test.go b/internal/daemon/handlers_comments_test.go index 7cf170205..6654b418d 100644 --- a/internal/daemon/handlers_comments_test.go +++ b/internal/daemon/handlers_comments_test.go @@ -270,3 +270,75 @@ func TestReopenIssue_BlankActorIs400(t *testing.T) { map[string]any{"actor": " "}) assertAPIError(t, resp.StatusCode, bs, 400, "validation") } + +func TestCommentEndpoint_IdempotencyKeyIsScopedToIssue(t *testing.T) { + h, ts, pid, firstIssueID := bootstrapProjectWithIssue(t) + second, _, err := h.DB().CreateIssue(t.Context(), db.CreateIssueParams{ + ProjectID: pid, Title: "second issue", Author: "agent", + }) + require.NoError(t, err) + body := map[string]any{"actor": "agent", "body": "same body"} + headers := map[string]string{"Idempotency-Key": "shared-key"} + + first := postWithHeader(t, ts, issueURL(pid, firstIssueID, "comments"), headers, body) + requireOK(t, first) + var firstOut struct { + Comment struct { + UID string `json:"uid"` + } `json:"comment"` + } + require.NoError(t, json.Unmarshal(first.body, &firstOut)) + + other := postWithHeader(t, ts, issueURLRef(pid, second.ShortID, "comments"), headers, body) + requireOK(t, other) + var otherOut struct { + Comment struct { + UID string `json:"uid"` + } `json:"comment"` + Changed bool `json:"changed"` + } + require.NoError(t, json.Unmarshal(other.body, &otherOut)) + assert.True(t, otherOut.Changed, "a key used on another issue must not replay the first issue's comment") + assert.NotEqual(t, firstOut.Comment.UID, otherOut.Comment.UID) + comments, err := h.DB().CommentsByIssue(t.Context(), second.ID) + require.NoError(t, err) + require.Len(t, comments, 1) +} + +func TestCommentEndpoint_IdempotencyReplayRequiresRelatedProject(t *testing.T) { + h, ts, pid, issueID := bootstrapProjectWithIssue(t) + issue, err := h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + body := map[string]any{"actor": "agent", "body": "first comment"} + headers := map[string]string{"Idempotency-Key": "comment-request-unrelated-route"} + requireOK(t, postWithHeader(t, ts, issueURLRef(pid, issue.UID, "comments"), headers, body)) + + unrelated, err := h.DB().CreateProject(t.Context(), "unrelated-project") + require.NoError(t, err) + retry := postWithHeader(t, ts, issueURLRef(unrelated.ID, issue.UID, "comments"), headers, body) + assertAPIError(t, retry.status, retry.body, http.StatusNotFound, "issue_not_found") +} + +func TestCommentEndpoint_IdempotencyReplayRejectsArchivedCurrentProject(t *testing.T) { + h, ts, sourceProjectID, issueID := bootstrapProjectWithIssue(t) + issue, err := h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + body := map[string]any{"actor": "agent", "body": "first comment"} + headers := map[string]string{"Idempotency-Key": "comment-request-then-archive"} + path := issueURLRef(sourceProjectID, issue.UID, "comments") + requireOK(t, postWithHeader(t, ts, path, headers, body)) + + target, err := h.DB().CreateProject(t.Context(), "archived-target") + require.NoError(t, err) + issue, err = h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + _, err = h.DB().MoveIssueProject(t.Context(), db.MoveIssueProjectIn{ + IssueID: issue.ID, FromProjectID: sourceProjectID, ToProjectID: target.ID, + IfMatchRev: issue.Revision, Actor: "coordinator", + }) + require.NoError(t, err) + archiveProject(t, h, target.ID, true) + + retry := postWithHeader(t, ts, path, headers, body) + assertAPIError(t, retry.status, retry.body, http.StatusNotFound, "project_not_found") +} diff --git a/internal/db/dbtest/conformance_core.go b/internal/db/dbtest/conformance_core.go index 659dcc364..0140ff5f8 100644 --- a/internal/db/dbtest/conformance_core.go +++ b/internal/db/dbtest/conformance_core.go @@ -329,7 +329,7 @@ func checkIdempotency(t *testing.T, store db.Storage) error { if err != nil { return fmt.Errorf("create comment: %w", err) } - commentMatch, err := store.LookupCommentIdempotency(ctx, project.ID, "", "comment-request-1", since) + commentMatch, err := store.LookupCommentIdempotency(ctx, issue.UID, "comment-request-1", since) if err != nil { return fmt.Errorf("lookup comment idempotency: %w", err) } @@ -338,14 +338,7 @@ func checkIdempotency(t *testing.T, store db.Storage) error { assert.Equal(t, issue.UID, commentMatch.IssueUID) assert.Equal(t, commentEvent.UID, commentMatch.Event.UID) assert.Equal(t, "comment-fingerprint-1", commentMatch.Fingerprint) - commentMatchByUID, err := store.LookupCommentIdempotency( - ctx, 0, issue.UID, "comment-request-1", since) - if err != nil { - return fmt.Errorf("lookup comment idempotency by issue UID: %w", err) - } - require.NotNil(t, commentMatchByUID) - assert.Equal(t, comment.UID, commentMatchByUID.Comment.UID) - missingComment, err := store.LookupCommentIdempotency(ctx, project.ID, "", "comment-request-2", since) + missingComment, err := store.LookupCommentIdempotency(ctx, issue.UID, "comment-request-2", since) if err != nil { return fmt.Errorf("lookup missing comment idempotency key: %w", err) } diff --git a/internal/db/pgstore/idempotency.go b/internal/db/pgstore/idempotency.go index beccbfe89..1ac7d763e 100644 --- a/internal/db/pgstore/idempotency.go +++ b/internal/db/pgstore/idempotency.go @@ -61,30 +61,20 @@ func (s *Store) LookupIssueMutationIdempotency( }, nil } -// LookupCommentIdempotency finds the newest recent issue.commented event -// carrying key and returns the comment that event created. +// LookupCommentIdempotency finds the newest recent issue.commented event on +// issueUID carrying key and returns the comment that event created. func (s *Store) LookupCommentIdempotency( ctx context.Context, - projectID int64, issueUID string, key string, since time.Time, ) (*db.CommentIdempotencyMatch, error) { query := eventSelect + ` WHERE e.type = 'issue.commented' - AND e.project_id = $1 - AND e.payload::jsonb ->> 'idempotency_key' = $2 - AND e.created_at >= $3 - ORDER BY e.id DESC LIMIT 1` - scope := any(projectID) - if issueUID != "" { - query = eventSelect + ` WHERE e.type = 'issue.commented' AND e.issue_uid = $1 AND e.payload::jsonb ->> 'idempotency_key' = $2 AND e.created_at >= $3 ORDER BY e.id DESC LIMIT 1` - scope = issueUID - } - event, err := scanEvent(s.QueryRowContext(ctx, query, scope, key, formatStoredTime(since))) + event, err := scanEvent(s.QueryRowContext(ctx, query, issueUID, key, formatStoredTime(since))) if errors.Is(err, db.ErrNotFound) { return nil, nil } diff --git a/internal/db/sqlitestore/queries_idempotency.go b/internal/db/sqlitestore/queries_idempotency.go index 704f44ff6..1578b9772 100644 --- a/internal/db/sqlitestore/queries_idempotency.go +++ b/internal/db/sqlitestore/queries_idempotency.go @@ -186,27 +186,12 @@ func (d *Store) LookupIssueMutationIdempotency( }, nil } -// LookupCommentIdempotency finds the newest recent issue.commented event -// carrying key and returns the comment that event created. +// LookupCommentIdempotency finds the newest recent issue.commented event on +// issueUID carrying key and returns the comment that event created. func (d *Store) LookupCommentIdempotency( - ctx context.Context, projectID int64, issueUID, key string, since time.Time, + ctx context.Context, issueUID, key string, since time.Time, ) (*db.CommentIdempotencyMatch, error) { - q := ` - SELECT e.id, e.uid, e.origin_instance_uid, e.project_id, p.uid, e.project_name, - e.issue_id, e.issue_uid, - e.related_issue_id, e.related_issue_uid, e.type, e.actor, e.payload, - e.hlc_physical_ms, e.hlc_counter, e.content_hash, e.created_at - FROM events e - JOIN projects p ON p.id = e.project_id - WHERE e.type = 'issue.commented' - AND e.project_id = ? - AND json_extract(e.payload, '$.idempotency_key') = ? - AND e.created_at >= ? - ORDER BY e.id DESC - LIMIT 1` - scope := any(projectID) - if issueUID != "" { - q = ` + const q = ` SELECT e.id, e.uid, e.origin_instance_uid, e.project_id, p.uid, e.project_name, e.issue_id, e.issue_uid, e.related_issue_id, e.related_issue_uid, e.type, e.actor, e.payload, @@ -219,9 +204,7 @@ func (d *Store) LookupCommentIdempotency( AND e.created_at >= ? ORDER BY e.id DESC LIMIT 1` - scope = issueUID - } - row := d.QueryRowContext(ctx, q, scope, key, since.UTC().Format(sqliteTimeFormat)) + row := d.QueryRowContext(ctx, q, issueUID, key, since.UTC().Format(sqliteTimeFormat)) var evt db.Event err := row.Scan(&evt.ID, &evt.UID, &evt.OriginInstanceUID, &evt.ProjectID, &evt.ProjectUID, &evt.ProjectName, diff --git a/internal/db/storage.go b/internal/db/storage.go index e4fdb2351..c5f6d9217 100644 --- a/internal/db/storage.go +++ b/internal/db/storage.go @@ -152,10 +152,9 @@ type Storage interface { AcquireIdempotencyLock(ctx context.Context, projectID int64, key string) (release func() error, err error) LookupIdempotency(ctx context.Context, projectID int64, key string, since time.Time) (*IdempotencyMatch, error) LookupIssueMutationIdempotency(ctx context.Context, projectID int64, eventType, key string, since time.Time) (*IdempotencyMatch, error) - // LookupCommentIdempotency scopes by issue UID when issueUID is non-empty; - // otherwise it scopes by project. UID scope lets a committed comment receipt - // survive a later project move without making keys global across issues. - LookupCommentIdempotency(ctx context.Context, projectID int64, issueUID, key string, since time.Time) (*CommentIdempotencyMatch, error) + // LookupCommentIdempotency scopes comment keys by issue UID so a committed + // receipt survives a later project move without making keys global. + LookupCommentIdempotency(ctx context.Context, issueUID, key string, since time.Time) (*CommentIdempotencyMatch, error) InsertCloseThrottledEvent(ctx context.Context, issueID int64, actor string, payload CloseThrottledPayload) (Event, error) RecentSiblingCloses(ctx context.Context, parentIssueID, excludeIssueID int64, actor string, since time.Time) ([]Event, error) RecentSameMessageClose(ctx context.Context, parentIssueID, excludeIssueID int64, actor, normalizedMessage string, since time.Time) (*Event, error) From acf8a91a895bc259a406280acdeccc25cdc18369 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Wed, 2 Sep 2026 22:06:05 -0500 Subject: [PATCH 14/14] Replay short-id comment retries after a move and reject deleted issues A keyed comment retry that names the issue by short ID resolves inside the route project. After the issue moves, that resolution fails and the retry returned 404, so the caller could not recover its receipt and might post the comment again in the new project. The receipt written in the route project still names the issue, so the handler now uses it to find the issue UID when the short ID is a suffix of that UID and any qualifier names the route project. The locked, issue-scoped lookup then replays as usual. A key alone cannot steer a retry to a different issue. A full-ULID retry skips ordinary resolution, so it replayed the comment and issue after the issue was soft-deleted. Replay now returns the same not-found response a fresh comment on a deleted issue gets. Generated with Claude Code Co-authored-by: Claude Fable 5.1 --- internal/daemon/handlers_comments.go | 51 ++++++++++++++++++++--- internal/daemon/handlers_comments_test.go | 49 ++++++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/internal/daemon/handlers_comments.go b/internal/daemon/handlers_comments.go index 13a4daee5..8b724aa0f 100644 --- a/internal/daemon/handlers_comments.go +++ b/internal/daemon/handlers_comments.go @@ -13,6 +13,7 @@ import ( "go.kenn.io/kata/internal/api" "go.kenn.io/kata/internal/db" + "go.kenn.io/kata/internal/shortid" "go.kenn.io/kata/internal/uid" ) @@ -33,22 +34,27 @@ func registerCommentsHandlers(humaAPI huma.API, cfg ServerConfig) { resolved := false fingerprint := "" if in.IdempotencyKey != "" { - if _, err := activeProjectByID(ctx, cfg.DB, in.ProjectID); err != nil { + routeProject, err := activeProjectByID(ctx, cfg.DB, in.ProjectID) + if err != nil { return nil, err } // Comment keys are scoped to one issue UID. A full ULID ref needs // no resolution, so its retry survives a project move; any other - // ref form resolves inside the route project first. + // ref form resolves inside the route project first and falls back + // to the receipt this project already holds for the key. issueUID := "" if uid.Valid(in.Ref) { issueUID = strings.ToUpper(in.Ref) } else { issue, err = activeIssueByRef(ctx, cfg.DB, in.ProjectID, in.Ref, db.IncludeDeletedNo) - if err != nil { + if err == nil { + resolved = true + issueUID = issue.UID + } else if issueUID, err = receiptIssueUID( + ctx, cfg, routeProject, in.Ref, in.IdempotencyKey, err, + ); err != nil { return nil, err } - resolved = true - issueUID = issue.UID } // Project IDs change when an issue moves. Zero is outside the // persisted project ID range and gives every keyed comment one @@ -178,7 +184,8 @@ func replayComment( if err != nil { return nil, internalAPIError(err) } - if routeProjectID != match.Event.ProjectID && routeProjectID != current.ProjectID { + if current.DeletedAt != nil || + (routeProjectID != match.Event.ProjectID && routeProjectID != current.ProjectID) { return nil, api.NewError(404, "issue_not_found", "issue not found", "", nil) } if _, err := activeProjectByID(ctx, cfg.DB, current.ProjectID); err != nil { @@ -195,6 +202,38 @@ func replayComment( return out, nil } +// receiptIssueUID recovers the issue a short-id retry addresses after that +// issue moved out of the route project. The receipt written in this project +// names the issue; the ref must still be a suffix of that issue's ULID, and a +// qualifier must name this project, so a key cannot steer a retry elsewhere. +// Any other outcome returns the original resolution error. +func receiptIssueUID( + ctx context.Context, + cfg ServerConfig, + routeProject db.Project, + ref, key string, + resolveErr error, +) (string, error) { + parsed, err := shortid.Parse(ref) + if err != nil || parsed.ShortID == "" || + (parsed.Project != "" && parsed.Project != routeProject.Name) { + return "", resolveErr + } + match, err := cfg.DB.LookupIssueMutationIdempotency( + ctx, routeProject.ID, "issue.commented", key, time.Now().Add(-idempotencyWindow)) + if err != nil { + return "", internalAPIError(err) + } + if match == nil { + return "", resolveErr + } + derived, err := shortid.Derive(match.IssueUID, len(parsed.ShortID)) + if err != nil || derived != parsed.ShortID { + return "", resolveErr + } + return match.IssueUID, nil +} + func commentIdempotencyFingerprint(issueUID, actor, body string) string { encoded, _ := json.Marshal(struct { IssueUID string `json:"issue_uid"` diff --git a/internal/daemon/handlers_comments_test.go b/internal/daemon/handlers_comments_test.go index 6654b418d..ac2573046 100644 --- a/internal/daemon/handlers_comments_test.go +++ b/internal/daemon/handlers_comments_test.go @@ -342,3 +342,52 @@ func TestCommentEndpoint_IdempotencyReplayRejectsArchivedCurrentProject(t *testi retry := postWithHeader(t, ts, path, headers, body) assertAPIError(t, retry.status, retry.body, http.StatusNotFound, "project_not_found") } + +func TestCommentEndpoint_IdempotencyReplaysShortIDRetryAfterMove(t *testing.T) { + h, ts, sourceProjectID, issueID := bootstrapProjectWithIssue(t) + body := map[string]any{"actor": "agent", "body": "first comment"} + headers := map[string]string{"Idempotency-Key": "short-id-comment-then-move"} + path := issueURL(sourceProjectID, issueID, "comments") + requireOK(t, postWithHeader(t, ts, path, headers, body)) + + target, err := h.DB().CreateProject(t.Context(), "target-project") + require.NoError(t, err) + issue, err := h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + _, err = h.DB().MoveIssueProject(t.Context(), db.MoveIssueProjectIn{ + IssueID: issue.ID, FromProjectID: sourceProjectID, ToProjectID: target.ID, + IfMatchRev: issue.Revision, Actor: "coordinator", + }) + require.NoError(t, err) + + retry := postWithHeader(t, ts, path, headers, body) + requireOK(t, retry) + var reused struct { + Changed bool `json:"changed"` + Issue struct { + ProjectID int64 `json:"project_id"` + } `json:"issue"` + } + require.NoError(t, json.Unmarshal(retry.body, &reused)) + assert.False(t, reused.Changed) + assert.Equal(t, target.ID, reused.Issue.ProjectID) + comments, err := h.DB().CommentsByIssue(t.Context(), issueID) + require.NoError(t, err) + require.Len(t, comments, 1) +} + +func TestCommentEndpoint_IdempotencyReplayRejectsSoftDeletedIssue(t *testing.T) { + h, ts, pid, issueID := bootstrapProjectWithIssue(t) + issue, err := h.DB().IssueByID(t.Context(), issueID) + require.NoError(t, err) + body := map[string]any{"actor": "agent", "body": "first comment"} + headers := map[string]string{"Idempotency-Key": "comment-then-delete"} + path := issueURLRef(pid, issue.UID, "comments") + requireOK(t, postWithHeader(t, ts, path, headers, body)) + + _, _, _, err = h.DB().SoftDeleteIssue(t.Context(), issueID, "agent") + require.NoError(t, err) + + retry := postWithHeader(t, ts, path, headers, body) + assertAPIError(t, retry.status, retry.body, http.StatusNotFound, "issue_not_found") +}