diff --git a/go/internal/forge/githubapp_webhook.go b/go/internal/forge/githubapp_webhook.go new file mode 100644 index 000000000..4778dd91d --- /dev/null +++ b/go/internal/forge/githubapp_webhook.go @@ -0,0 +1,278 @@ +package forge + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" +) + +// actionCompleted is the GitHub webhook action for a finished check_suite. +const actionCompleted = "completed" + +// VerifyGitHubSignature reports whether headerValue is the HMAC-SHA256 of +// rawBody under secret. headerValue is the X-Hub-Signature-256 header, shaped +// "sha256=". The comparison is constant-time; any missing prefix or +// hex-decode error returns false. Mirrors linearagent.VerifySignature +// (webhook.go:65-73), adapted to GitHub's prefixed header shape. +func VerifyGitHubSignature(secret, rawBody []byte, headerValue string) bool { + hexPart, ok := strings.CutPrefix(headerValue, "sha256=") + if !ok { + return false + } + want, err := hex.DecodeString(hexPart) + if err != nil { + return false + } + mac := hmac.New(sha256.New, secret) + mac.Write(rawBody) + return hmac.Equal(want, mac.Sum(nil)) +} + +// whUser is the actor sub-object GitHub attaches to comments and artifacts. +type whUser struct { + Login string `json:"login"` +} + +// whComment is a GitHub issue/PR/review comment (webhook shape). +type whComment struct { + ID uint64 `json:"id"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` + User whUser `json:"user"` +} + +// whIssue is a GitHub issue (also the carrier on issue_comment). A non-empty +// PullRequest marker means the issue is actually a pull request. +type whIssue struct { + Number uint64 `json:"number"` + HTMLURL string `json:"html_url"` + State string `json:"state"` + PullRequest json.RawMessage `json:"pull_request"` +} + +// whPullRequest is a GitHub pull request (webhook shape). +type whPullRequest struct { + Number uint64 `json:"number"` + HTMLURL string `json:"html_url"` + State string `json:"state"` + Merged bool `json:"merged"` +} + +// whCheckSuite is the check_suite sub-object. +type whCheckSuite struct { + HeadSHA string `json:"head_sha"` +} + +// whRepository is the repository sub-object (owner/name via full_name). +type whRepository struct { + FullName string `json:"full_name"` +} + +// whReview is the pull_request_review sub-object. +type whReview struct { + ID uint64 `json:"id"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` + State string `json:"state"` + User whUser `json:"user"` +} + +// whPayload is the union of the GitHub webhook payload fields this arm reads. +// A single struct covers all event types (only the relevant sub-objects are +// populated per event), matching the flat json.Unmarshal shape the arm needs. +type whPayload struct { + Action string `json:"action"` + Issue *whIssue `json:"issue"` + PullRequest *whPullRequest `json:"pull_request"` + Comment *whComment `json:"comment"` + CheckSuite *whCheckSuite `json:"check_suite"` + Review *whReview `json:"review"` + Repository whRepository `json:"repository"` +} + +// ParseGitHubEvent maps (X-GitHub-Event, raw body) to a normalized ForgeEvent, +// or ok=false for an event/action this arm ignores (counted-and-dropped by the +// caller, never an error). The mapping follows the frozen Approach event table +// (design.md:131-142). Bodies run StripOwner here — normalize is the one strip +// point (design.md:554-557, 657-659). +func ParseGitHubEvent(event string, body []byte) (ev ForgeEvent, ok bool, err error) { + var wh whPayload + if uerr := json.Unmarshal(body, &wh); uerr != nil { + return ForgeEvent{}, false, fmt.Errorf("forge: parse github %s event: %w", event, uerr) + } + + base := ForgeEvent{ + Provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, + Host: "github.com", + Repo: wh.Repository.FullName, + } + + switch event { + case "issues": + return parseGitHubIssues(base, wh) + case "issue_comment": + return parseGitHubIssueComment(base, wh) + case "pull_request": + return parseGitHubPullRequest(base, wh) + case "pull_request_review": + return parseGitHubReview(base, wh) + case "pull_request_review_comment": + return parseGitHubReviewComment(base, wh) + case "check_suite": + return parseGitHubCheckSuite(base, wh) + default: + return ForgeEvent{}, false, nil + } +} + +func parseGitHubIssues(base ForgeEvent, wh whPayload) (ForgeEvent, bool, error) { + if wh.Issue == nil { + return ForgeEvent{}, false, nil + } + change, ok := gitHubStateOrUpdateKind(wh.Action) + if !ok { + return ForgeEvent{}, false, nil + } + base.Kind = compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_ISSUE + base.Number = wh.Issue.Number + base.URL = wh.Issue.HTMLURL + base.Change = change + if change == compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_STATE { + base.State = wh.Issue.State + } + return base, true, nil +} + +func parseGitHubPullRequest(base ForgeEvent, wh whPayload) (ForgeEvent, bool, error) { + if wh.PullRequest == nil { + return ForgeEvent{}, false, nil + } + change, ok := gitHubStateOrUpdateKind(wh.Action) + if !ok { + return ForgeEvent{}, false, nil + } + base.Kind = compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_PULL_REQUEST + base.Number = wh.PullRequest.Number + base.URL = wh.PullRequest.HTMLURL + base.Change = change + if change == compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_STATE { + base.State = gitHubPRState(wh.PullRequest) + } + return base, true, nil +} + +// gitHubStateOrUpdateKind maps an issues/pull_request action to its +// notification kind per the event table: opened->OPENED, +// closed/reopened->STATE, edited/labeled/unlabeled->UPDATE. Any other action +// (assigned, milestoned, …) is ignored (ok=false). +func gitHubStateOrUpdateKind(action string) (compassv1internal.ForgeNotificationKind, bool) { + switch action { + case "opened": + return compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_OPENED, true + case "closed", "reopened": + return compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_STATE, true + case "edited", "labeled", "unlabeled": + return compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_UPDATE, true + default: + return compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_UNSPECIFIED, false + } +} + +// gitHubPRState renders the PR's forge state: "merged" if merged, else the raw +// state ("closed"/"open"). +func gitHubPRState(pr *whPullRequest) string { + if pr.Merged { + return "merged" + } + return pr.State +} + +func parseGitHubIssueComment(base ForgeEvent, wh whPayload) (ForgeEvent, bool, error) { + if wh.Action != "created" || wh.Issue == nil || wh.Comment == nil { + return ForgeEvent{}, false, nil + } + // GitHub serves PR conversation comments on this event too; the issue's + // pull_request marker discriminates PR from issue (design.md:136, 653-654). + base.Kind = compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_ISSUE + if len(wh.Issue.PullRequest) > 0 { + base.Kind = compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_PULL_REQUEST + } + base.Number = wh.Issue.Number + base.URL = wh.Comment.HTMLURL + base.Change = compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_COMMENT + base.Comment = gitHubCommentRef(wh.Comment) + return base, true, nil +} + +func parseGitHubReviewComment(base ForgeEvent, wh whPayload) (ForgeEvent, bool, error) { + if wh.Action != "created" || wh.PullRequest == nil || wh.Comment == nil { + return ForgeEvent{}, false, nil + } + base.Kind = compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_PULL_REQUEST + base.Number = wh.PullRequest.Number + base.URL = wh.Comment.HTMLURL + base.Change = compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_COMMENT + base.Comment = gitHubCommentRef(wh.Comment) + return base, true, nil +} + +func parseGitHubReview(base ForgeEvent, wh whPayload) (ForgeEvent, bool, error) { + if wh.Action != "submitted" || wh.PullRequest == nil || wh.Review == nil { + return ForgeEvent{}, false, nil + } + base.Kind = compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_PULL_REQUEST + base.Number = wh.PullRequest.Number + base.URL = wh.Review.HTMLURL + base.Change = compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_REVIEW + base.State = wh.Review.State + base.Comment = &compassv1internal.CommentRef{ + Url: wh.Review.HTMLURL, + CommentId: wh.Review.ID, + ForgeAccount: wh.Review.User.Login, + } + base.Comment.Body, base.Comment.Agent = stripBodyToRef(wh.Review.Body) + return base, true, nil +} + +func parseGitHubCheckSuite(base ForgeEvent, wh whPayload) (ForgeEvent, bool, error) { + if wh.Action != actionCompleted || wh.CheckSuite == nil { + return ForgeEvent{}, false, nil + } + // A check_suite has no artifact number and carries NO ChecksSummary — a + // suite is per-App, never roll-up truth; T4's router fetches the combined + // roll-up for the head SHA (design.md:144-155, 655-657). + base.Kind = compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_PULL_REQUEST + base.Change = compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_CHECKS + base.HeadSHA = wh.CheckSuite.HeadSHA + return base, true, nil +} + +// gitHubCommentRef builds a CommentRef from a GitHub comment, running +// StripOwner on the body (the one strip point) and surfacing the parsed agent +// claim only when a single well-formed header was present. +func gitHubCommentRef(c *whComment) *compassv1internal.CommentRef { + ref := &compassv1internal.CommentRef{ + Url: c.HTMLURL, + CommentId: c.ID, + ForgeAccount: c.User.Login, + } + ref.Body, ref.Agent = stripBodyToRef(c.Body) + return ref +} + +// stripBodyToRef runs StripOwner over a raw forge body and returns the cleaned +// body plus the agent attribution claim (nil unless a single well-formed v1 +// header was present). The owner claim is display-only (DL-050/DL-094). +func stripBodyToRef(raw string) (string, *compassv1.AgentAttribution) { + clean, author, ok := StripOwner(raw) + if !ok { + return clean, nil + } + return clean, &compassv1.AgentAttribution{AgentHandle: author.AgentHandle} +} diff --git a/go/internal/forge/githubapp_webhook_test.go b/go/internal/forge/githubapp_webhook_test.go new file mode 100644 index 000000000..c02f0dcda --- /dev/null +++ b/go/internal/forge/githubapp_webhook_test.go @@ -0,0 +1,298 @@ +package forge + +// Unit tests for the GitHub App webhook ingress: the constant-time signature +// verifier and the (event, body) -> ForgeEvent normalizer. Covers the T2 test +// cycle (design.md:667-674): signature vectors (valid/tampered/missing), every +// Approach event-table row (design.md:131-142) parses to the right +// kind/coordinate/payload, ignored actions -> ok=false, PR-vs-issue comment +// discrimination, check_suite.completed -> HeadSHA set + Checks nil, and +// StripOwner applied at normalize. + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "testing" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" +) + +func sign(secret, body []byte) string { + mac := hmac.New(sha256.New, secret) + mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +func TestVerifyGitHubSignature(t *testing.T) { + secret := []byte("s3cr3t") + body := []byte(`{"hello":"world"}`) + valid := sign(secret, body) + + cases := []struct { + name string + header string + body []byte + want bool + }{ + {"valid", valid, body, true}, + {"tampered body", valid, []byte(`{"hello":"mars"}`), false}, + {"tampered header", "sha256=deadbeef", body, false}, + {"missing prefix", hex.EncodeToString([]byte("x")), body, false}, + {"empty header", "", body, false}, + {"non-hex", "sha256=zzzz", body, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := VerifyGitHubSignature(secret, tc.body, tc.header); got != tc.want { + t.Fatalf("VerifyGitHubSignature = %v, want %v", got, tc.want) + } + }) + } +} + +func TestParseGitHubEvent_Table(t *testing.T) { //nolint:funlen // one row per Approach event-table entry; splitting the fixtures would scatter the mapping contract + const ( + issueK = compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_ISSUE + prK = compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_PULL_REQUEST + ) + + cases := []struct { + name string + event string + body string + wantOK bool + wantKind compassv1internal.ForgeArtifactKind + wantChange compassv1internal.ForgeNotificationKind + wantNumber uint64 + wantState string + }{ + { + name: "issues opened -> OPENED", + event: "issues", + body: `{"action":"opened","issue":{"number":12,"html_url":"u","state":"open"},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: issueK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_OPENED, + wantNumber: 12, + }, + { + name: "issues closed -> STATE", + event: "issues", + body: `{"action":"closed","issue":{"number":3,"html_url":"u","state":"closed"},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: issueK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_STATE, + wantNumber: 3, + wantState: "closed", + }, + { + name: "issues reopened -> STATE", + event: "issues", + body: `{"action":"reopened","issue":{"number":5,"html_url":"u","state":"open"},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: issueK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_STATE, + wantNumber: 5, + wantState: "open", + }, + { + name: "issues edited -> UPDATE", + event: "issues", + body: `{"action":"edited","issue":{"number":4,"html_url":"u"},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: issueK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_UPDATE, + wantNumber: 4, + }, + { + name: "pull_request opened -> OPENED", + event: "pull_request", + body: `{"action":"opened","pull_request":{"number":9,"html_url":"u","state":"open"},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: prK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_OPENED, + wantNumber: 9, + }, + { + name: "pull_request merged -> STATE merged", + event: "pull_request", + body: `{"action":"closed","pull_request":{"number":9,"html_url":"u","state":"closed","merged":true},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: prK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_STATE, + wantNumber: 9, + wantState: "merged", + }, + { + name: "pull_request labeled -> UPDATE", + event: "pull_request", + body: `{"action":"labeled","pull_request":{"number":9,"html_url":"u"},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: prK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_UPDATE, + wantNumber: 9, + }, + { + name: "pull_request unlabeled -> UPDATE", + event: "pull_request", + body: `{"action":"unlabeled","pull_request":{"number":9,"html_url":"u"},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: prK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_UPDATE, + wantNumber: 9, + }, + { + name: "pull_request_review submitted -> REVIEW", + event: "pull_request_review", + body: `{"action":"submitted","pull_request":{"number":9,"html_url":"u"},"review":{"id":77,"html_url":"ru","body":"lgtm","state":"approved","user":{"login":"rev"}},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: prK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_REVIEW, + wantNumber: 9, + wantState: "approved", + }, + { + name: "pull_request_review_comment created -> COMMENT", + event: "pull_request_review_comment", + body: `{"action":"created","pull_request":{"number":9,"html_url":"u"},"comment":{"id":5,"html_url":"cu","body":"nit","user":{"login":"c"}},"repository":{"full_name":"o/r"}}`, + wantOK: true, + wantKind: prK, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_COMMENT, + wantNumber: 9, + }, + { + name: "issues assigned -> ignored", + event: "issues", + body: `{"action":"assigned","issue":{"number":1},"repository":{"full_name":"o/r"}}`, + wantOK: false, + }, + { + name: "unknown event -> ignored", + event: "push", + body: `{"ref":"refs/heads/main"}`, + wantOK: false, + }, + { + name: "issue_comment non-created -> ignored", + event: "issue_comment", + body: `{"action":"deleted","issue":{"number":1},"comment":{"id":1},"repository":{"full_name":"o/r"}}`, + wantOK: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ev, ok, err := ParseGitHubEvent(tc.event, []byte(tc.body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + return + } + if ev.Provider != compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB { + t.Errorf("Provider = %v, want GITHUB", ev.Provider) + } + if ev.Host != "github.com" { + t.Errorf("Host = %q, want github.com", ev.Host) + } + if ev.Repo != "o/r" { + t.Errorf("Repo = %q, want o/r", ev.Repo) + } + if ev.Kind != tc.wantKind { + t.Errorf("Kind = %v, want %v", ev.Kind, tc.wantKind) + } + if ev.Change != tc.wantChange { + t.Errorf("Change = %v, want %v", ev.Change, tc.wantChange) + } + if ev.Number != tc.wantNumber { + t.Errorf("Number = %d, want %d", ev.Number, tc.wantNumber) + } + if ev.State != tc.wantState { + t.Errorf("State = %q, want %q", ev.State, tc.wantState) + } + }) + } +} + +// TestParseGitHubEvent_CommentPRvsIssue asserts the issue.pull_request marker +// discriminates a PR conversation comment (Kind=PR) from a plain issue comment +// (Kind=ISSUE) on the single issue_comment event (design.md:136, 653-654). +func TestParseGitHubEvent_CommentPRvsIssue(t *testing.T) { + issueBody := `{"action":"created","issue":{"number":8,"html_url":"iu"},"comment":{"id":2,"html_url":"cu","body":"hi","user":{"login":"c"}},"repository":{"full_name":"o/r"}}` + prBody := `{"action":"created","issue":{"number":8,"html_url":"iu","pull_request":{"url":"p"}},"comment":{"id":2,"html_url":"cu","body":"hi","user":{"login":"c"}},"repository":{"full_name":"o/r"}}` + + ev, ok, err := ParseGitHubEvent("issue_comment", []byte(issueBody)) + if err != nil || !ok { + t.Fatalf("issue comment parse: ok=%v err=%v", ok, err) + } + if ev.Kind != compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_ISSUE { + t.Errorf("issue comment Kind = %v, want ISSUE", ev.Kind) + } + if ev.Comment == nil || ev.Comment.GetForgeAccount() != "c" { + t.Errorf("issue comment ref = %+v, want forge_account c", ev.Comment) + } + + ev, ok, err = ParseGitHubEvent("issue_comment", []byte(prBody)) + if err != nil || !ok { + t.Fatalf("pr comment parse: ok=%v err=%v", ok, err) + } + if ev.Kind != compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_PULL_REQUEST { + t.Errorf("pr comment Kind = %v, want PULL_REQUEST", ev.Kind) + } +} + +// TestParseGitHubEvent_CheckSuite asserts check_suite.completed carries HeadSHA +// and a NIL ChecksSummary — the roll-up is the router's fetch, never parse-time +// (design.md:144-155, 655-657). +func TestParseGitHubEvent_CheckSuite(t *testing.T) { + body := `{"action":"completed","check_suite":{"head_sha":"abc123"},"repository":{"full_name":"o/r"}}` + ev, ok, err := ParseGitHubEvent("check_suite", []byte(body)) + if err != nil || !ok { + t.Fatalf("check_suite parse: ok=%v err=%v", ok, err) + } + if ev.Change != compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_CHECKS { + t.Errorf("Change = %v, want CHECKS", ev.Change) + } + if ev.HeadSHA != "abc123" { + t.Errorf("HeadSHA = %q, want abc123", ev.HeadSHA) + } + if ev.Checks != nil { + t.Errorf("Checks = %v, want nil (router fills it)", ev.Checks) + } + + // A non-completed check_suite action is ignored. + if _, ok, _ := ParseGitHubEvent("check_suite", []byte(`{"action":"requested","check_suite":{"head_sha":"x"},"repository":{"full_name":"o/r"}}`)); ok { + t.Error("check_suite.requested ok = true, want false") + } +} + +// TestParseGitHubEvent_StripsOwner asserts a commented body carrying a single +// well-formed owner header is stripped at normalize and the agent claim +// surfaced (design.md:554-557, 657-659). +func TestParseGitHubEvent_StripsOwner(t *testing.T) { + stamped, err := StampOwner("the real comment", Author{AgentHandle: "agent-x", OwnerHandle: "owner-y", SessionID: "sess-1"}, 0) + if err != nil { + t.Fatalf("stamp: %v", err) + } + // Embed the stamped body as a JSON string. + bodyJSON, err := json.Marshal(stamped) + if err != nil { + t.Fatalf("marshal: %v", err) + } + payload := `{"action":"created","issue":{"number":8,"html_url":"iu"},"comment":{"id":2,"html_url":"cu","body":` + string(bodyJSON) + `,"user":{"login":"c"}},"repository":{"full_name":"o/r"}}` + + ev, ok, err := ParseGitHubEvent("issue_comment", []byte(payload)) + if err != nil || !ok { + t.Fatalf("parse: ok=%v err=%v", ok, err) + } + if ev.Comment.GetBody() != "the real comment" { + t.Errorf("stripped body = %q, want %q", ev.Comment.GetBody(), "the real comment") + } + if ev.Comment.GetAgent().GetAgentHandle() != "agent-x" { + t.Errorf("agent claim = %q, want agent-x", ev.Comment.GetAgent().GetAgentHandle()) + } +} diff --git a/go/internal/forge/notify_event.go b/go/internal/forge/notify_event.go new file mode 100644 index 000000000..0baa114a5 --- /dev/null +++ b/go/internal/forge/notify_event.go @@ -0,0 +1,56 @@ +package forge + +import ( + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" +) + +// ForgeEvent is the pipeline's single normalized currency: the shape both the +// GitHub webhook arm (githubapp_webhook.go) and the Linear data-change arm +// (internal/linearagent) produce, and the router (T4) consumes. It is a +// server-internal value; it is not a wire type. +// +// Field types are grounded on the frozen wire currency (the ForgeNotification +// gen message, internal/gen/compass/v1/forge.pb.go:326-341): Change is the +// notification kind, Comment a *CommentRef, Checks a *v1.ChecksSummary, Kind +// the artifact kind. Provider is the compass.v1 ForgeProvider enum. +type ForgeEvent struct { + // Provider is the forge this event came from (GITHUB / LINEAR). + Provider compassv1.ForgeProvider + // Host is the forge host ("github.com", a GHES host, "linear.app"). + Host string + // Repo is the GitHub owner/name, or the Linear team key. + Repo string + // Kind is the artifact kind: issue(1) or pull_request(2). + Kind compassv1internal.ForgeArtifactKind + // Number is the artifact's number (always set; on OPENED it is the NEW + // artifact's number). + Number uint64 + // Project is the Linear issue's project id (container matching, W2); "" on + // GitHub events. + Project string + // URL is the artifact's (or comment's) canonical web URL. + URL string + // Change is the notification kind this event maps to. + Change compassv1internal.ForgeNotificationKind + // Comment is set for COMMENT / REVIEW: the new comment, header-stripped and + // author-attributed. + Comment *compassv1internal.CommentRef + // Checks is set for CHECKS, but only by the router's roll-up fetch (T4), + // never at parse time — a check_suite is per-App, not roll-up truth. + Checks *compassv1.ChecksSummary + // HeadSHA is set for CHECKS: the completed suite's head SHA. + HeadSHA string + // State is the new forge state string for STATE / the verdict for REVIEW. + State string + // DeliveryID is the provider's delivery UUID (X-GitHub-Delivery / + // Linear-Delivery), used by the mount's dedup LRU. + DeliveryID string +} + +// MapLinearState exposes the package's Linear workflow-state -> forge +// open/closed truth mapping (linear.go:730) to the linearagent data-change +// arm, which normalizes a STATE event's verdict through the same mapping. +func MapLinearState(stateType string) string { + return mapLinearState(stateType) +} diff --git a/go/internal/gen/compass/v1/agent.pb.go b/go/internal/gen/compass/v1/agent.pb.go index fcd3ae8d0..615367ac5 100644 --- a/go/internal/gen/compass/v1/agent.pb.go +++ b/go/internal/gen/compass/v1/agent.pb.go @@ -63,6 +63,7 @@ type AgentFrame struct { // *AgentFrame_ControlAck // *AgentFrame_DeliveryAck // *AgentFrame_TranscriptEntry + // *AgentFrame_ForgeNotificationAck Frame isAgentFrame_Frame `protobuf_oneof:"frame"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -150,6 +151,15 @@ func (x *AgentFrame) GetTranscriptEntry() *TranscriptEntry { return nil } +func (x *AgentFrame) GetForgeNotificationAck() *ForgeNotificationAck { + if x != nil { + if x, ok := x.Frame.(*AgentFrame_ForgeNotificationAck); ok { + return x.ForgeNotificationAck + } + } + return nil +} + type isAgentFrame_Frame interface { isAgentFrame_Frame() } @@ -211,6 +221,16 @@ type AgentFrame_TranscriptEntry struct { TranscriptEntry *TranscriptEntry `protobuf:"bytes,7,opt,name=transcript_entry,json=transcriptEntry,proto3,oneof"` } +type AgentFrame_ForgeNotificationAck struct { + // forge_notification_ack — the agent's per-notification receipt for a + // + // ForgeNotification pushed down the session (W3; forge sibling of + // delivery_ack). Emitted at turn-end flush (T6), applied by a hub ack + // arm beside deliverAck (T7): on receipt the Server advances the + // subscription's delivered_revision to the acked revision. + ForgeNotificationAck *ForgeNotificationAck `protobuf:"bytes,8,opt,name=forge_notification_ack,json=forgeNotificationAck,proto3,oneof"` +} + func (*AgentFrame_Session) isAgentFrame_Frame() {} func (*AgentFrame_ReplayCompleteAck) isAgentFrame_Frame() {} @@ -221,6 +241,8 @@ func (*AgentFrame_DeliveryAck) isAgentFrame_Frame() {} func (*AgentFrame_TranscriptEntry) isAgentFrame_Frame() {} +func (*AgentFrame_ForgeNotificationAck) isAgentFrame_Frame() {} + // The `transcript_entry` variant's payload: one committed SDK session entry, // teed upstream by the agent's session-storage backend as a durable frame // (SEA-1570). `append` → a delta entry (checkpoint = false); `writeFull` (an @@ -918,6 +940,64 @@ func (x *DeliveryAck) GetMessageId() string { return "" } +// ForgeNotificationAck — the agent's per-notification delivery receipt (W3), an +// AgentFrame oneof variant riding the Publish spine beside DeliveryAck. Where +// DeliveryAck correlates a comms delivery by message_id, this correlates a forge +// notification by subscription_id and carries the notified `revision` (the +// advance target): on receipt the Server advances that subscription's +// delivered_revision (T7 hub ack arm; store AdvanceForgeDeliveredRevision). +type ForgeNotificationAck struct { + state protoimpl.MessageState `protogen:"open.v1"` + SubscriptionId string `protobuf:"bytes,1,opt,name=subscription_id,json=subscriptionId,proto3" json:"subscription_id,omitempty"` + Revision string `protobuf:"bytes,2,opt,name=revision,proto3" json:"revision,omitempty"` // the notified revision; the advance target + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ForgeNotificationAck) Reset() { + *x = ForgeNotificationAck{} + mi := &file_compass_v1_agent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ForgeNotificationAck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForgeNotificationAck) ProtoMessage() {} + +func (x *ForgeNotificationAck) ProtoReflect() protoreflect.Message { + mi := &file_compass_v1_agent_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForgeNotificationAck.ProtoReflect.Descriptor instead. +func (*ForgeNotificationAck) Descriptor() ([]byte, []int) { + return file_compass_v1_agent_proto_rawDescGZIP(), []int{11} +} + +func (x *ForgeNotificationAck) GetSubscriptionId() string { + if x != nil { + return x.SubscriptionId + } + return "" +} + +func (x *ForgeNotificationAck) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + // Two agent -> Runner control-plane ACK frames, added as AgentFrame oneof // variants above (riding the loss-tolerable Publish spine beside DeliveryAck, // the established frame-spine ack convention — consolidation OQ-4(i) + amended @@ -930,7 +1010,7 @@ type ReplayCompleteAck struct { func (x *ReplayCompleteAck) Reset() { *x = ReplayCompleteAck{} - mi := &file_compass_v1_agent_proto_msgTypes[11] + mi := &file_compass_v1_agent_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -942,7 +1022,7 @@ func (x *ReplayCompleteAck) String() string { func (*ReplayCompleteAck) ProtoMessage() {} func (x *ReplayCompleteAck) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_proto_msgTypes[11] + mi := &file_compass_v1_agent_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -955,7 +1035,7 @@ func (x *ReplayCompleteAck) ProtoReflect() protoreflect.Message { // Deprecated: Use ReplayCompleteAck.ProtoReflect.Descriptor instead. func (*ReplayCompleteAck) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_proto_rawDescGZIP(), []int{11} + return file_compass_v1_agent_proto_rawDescGZIP(), []int{12} } type ControlAck struct { @@ -972,7 +1052,7 @@ type ControlAck struct { func (x *ControlAck) Reset() { *x = ControlAck{} - mi := &file_compass_v1_agent_proto_msgTypes[12] + mi := &file_compass_v1_agent_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -984,7 +1064,7 @@ func (x *ControlAck) String() string { func (*ControlAck) ProtoMessage() {} func (x *ControlAck) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_proto_msgTypes[12] + mi := &file_compass_v1_agent_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -997,7 +1077,7 @@ func (x *ControlAck) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlAck.ProtoReflect.Descriptor instead. func (*ControlAck) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_proto_rawDescGZIP(), []int{12} + return file_compass_v1_agent_proto_rawDescGZIP(), []int{13} } func (x *ControlAck) GetAckedSeq() uint64 { @@ -1019,7 +1099,7 @@ var File_compass_v1_agent_proto protoreflect.FileDescriptor const file_compass_v1_agent_proto_rawDesc = "" + "\n" + "\x16compass/v1/agent.proto\x12\n" + - "compass.v1\x1a\x16compass/v1/comms.proto\x1a\x18compass/v1/compass.proto\x1a\x16compass/v1/forge.proto\"\xdf\x02\n" + + "compass.v1\x1a\x16compass/v1/comms.proto\x1a\x18compass/v1/compass.proto\x1a\x16compass/v1/forge.proto\"\xb9\x03\n" + "\n" + "AgentFrame\x124\n" + "\asession\x18\x03 \x01(\v2\x18.compass.v1.SessionFrameH\x00R\asession\x12O\n" + @@ -1027,7 +1107,8 @@ const file_compass_v1_agent_proto_rawDesc = "" + "\vcontrol_ack\x18\x05 \x01(\v2\x16.compass.v1.ControlAckH\x00R\n" + "controlAck\x12<\n" + "\fdelivery_ack\x18\x06 \x01(\v2\x17.compass.v1.DeliveryAckH\x00R\vdeliveryAck\x12H\n" + - "\x10transcript_entry\x18\a \x01(\v2\x1b.compass.v1.TranscriptEntryH\x00R\x0ftranscriptEntryB\a\n" + + "\x10transcript_entry\x18\a \x01(\v2\x1b.compass.v1.TranscriptEntryH\x00R\x0ftranscriptEntry\x12X\n" + + "\x16forge_notification_ack\x18\b \x01(\v2 .compass.v1.ForgeNotificationAckH\x00R\x14forgeNotificationAckB\a\n" + "\x05frame\"m\n" + "\x0fTranscriptEntry\x12\x1d\n" + "\n" + @@ -1068,7 +1149,10 @@ const file_compass_v1_agent_proto_rawDesc = "" + "fromHandle\",\n" + "\vDeliveryAck\x12\x1d\n" + "\n" + - "message_id\x18\x01 \x01(\tR\tmessageId\"\x13\n" + + "message_id\x18\x01 \x01(\tR\tmessageId\"[\n" + + "\x14ForgeNotificationAck\x12'\n" + + "\x0fsubscription_id\x18\x01 \x01(\tR\x0esubscriptionId\x12\x1a\n" + + "\brevision\x18\x02 \x01(\tR\brevision\"\x13\n" + "\x11ReplayCompleteAck\"N\n" + "\n" + "ControlAck\x12\x1b\n" + @@ -1087,48 +1171,50 @@ func file_compass_v1_agent_proto_rawDescGZIP() []byte { return file_compass_v1_agent_proto_rawDescData } -var file_compass_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_compass_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_compass_v1_agent_proto_goTypes = []any{ - (*AgentFrame)(nil), // 0: compass.v1.AgentFrame - (*TranscriptEntry)(nil), // 1: compass.v1.TranscriptEntry - (*SessionFrame)(nil), // 2: compass.v1.SessionFrame - (*AgentControl)(nil), // 3: compass.v1.AgentControl - (*PromptControl)(nil), // 4: compass.v1.PromptControl - (*ReplayComplete)(nil), // 5: compass.v1.ReplayComplete - (*SteerControl)(nil), // 6: compass.v1.SteerControl - (*TranscriptReplay)(nil), // 7: compass.v1.TranscriptReplay - (*ConfigControl)(nil), // 8: compass.v1.ConfigControl - (*DeliverControl)(nil), // 9: compass.v1.DeliverControl - (*DeliveryAck)(nil), // 10: compass.v1.DeliveryAck - (*ReplayCompleteAck)(nil), // 11: compass.v1.ReplayCompleteAck - (*ControlAck)(nil), // 12: compass.v1.ControlAck - (v1.AgentSessionState)(0), // 13: compass.v1.AgentSessionState - (*v1.SessionEvent)(nil), // 14: compass.v1.SessionEvent - (*ForgeNotification)(nil), // 15: compass.v1.ForgeNotification - (*v1.Message)(nil), // 16: compass.v1.Message + (*AgentFrame)(nil), // 0: compass.v1.AgentFrame + (*TranscriptEntry)(nil), // 1: compass.v1.TranscriptEntry + (*SessionFrame)(nil), // 2: compass.v1.SessionFrame + (*AgentControl)(nil), // 3: compass.v1.AgentControl + (*PromptControl)(nil), // 4: compass.v1.PromptControl + (*ReplayComplete)(nil), // 5: compass.v1.ReplayComplete + (*SteerControl)(nil), // 6: compass.v1.SteerControl + (*TranscriptReplay)(nil), // 7: compass.v1.TranscriptReplay + (*ConfigControl)(nil), // 8: compass.v1.ConfigControl + (*DeliverControl)(nil), // 9: compass.v1.DeliverControl + (*DeliveryAck)(nil), // 10: compass.v1.DeliveryAck + (*ForgeNotificationAck)(nil), // 11: compass.v1.ForgeNotificationAck + (*ReplayCompleteAck)(nil), // 12: compass.v1.ReplayCompleteAck + (*ControlAck)(nil), // 13: compass.v1.ControlAck + (v1.AgentSessionState)(0), // 14: compass.v1.AgentSessionState + (*v1.SessionEvent)(nil), // 15: compass.v1.SessionEvent + (*ForgeNotification)(nil), // 16: compass.v1.ForgeNotification + (*v1.Message)(nil), // 17: compass.v1.Message } var file_compass_v1_agent_proto_depIdxs = []int32{ 2, // 0: compass.v1.AgentFrame.session:type_name -> compass.v1.SessionFrame - 11, // 1: compass.v1.AgentFrame.replay_complete_ack:type_name -> compass.v1.ReplayCompleteAck - 12, // 2: compass.v1.AgentFrame.control_ack:type_name -> compass.v1.ControlAck + 12, // 1: compass.v1.AgentFrame.replay_complete_ack:type_name -> compass.v1.ReplayCompleteAck + 13, // 2: compass.v1.AgentFrame.control_ack:type_name -> compass.v1.ControlAck 10, // 3: compass.v1.AgentFrame.delivery_ack:type_name -> compass.v1.DeliveryAck 1, // 4: compass.v1.AgentFrame.transcript_entry:type_name -> compass.v1.TranscriptEntry - 13, // 5: compass.v1.SessionFrame.state:type_name -> compass.v1.AgentSessionState - 14, // 6: compass.v1.SessionFrame.typed_event:type_name -> compass.v1.SessionEvent - 4, // 7: compass.v1.AgentControl.prompt:type_name -> compass.v1.PromptControl - 6, // 8: compass.v1.AgentControl.steer:type_name -> compass.v1.SteerControl - 9, // 9: compass.v1.AgentControl.deliver:type_name -> compass.v1.DeliverControl - 8, // 10: compass.v1.AgentControl.config:type_name -> compass.v1.ConfigControl - 7, // 11: compass.v1.AgentControl.replay:type_name -> compass.v1.TranscriptReplay - 5, // 12: compass.v1.AgentControl.replay_complete:type_name -> compass.v1.ReplayComplete - 15, // 13: compass.v1.AgentControl.forge_notification:type_name -> compass.v1.ForgeNotification - 16, // 14: compass.v1.SteerControl.message:type_name -> compass.v1.Message - 16, // 15: compass.v1.DeliverControl.message:type_name -> compass.v1.Message - 16, // [16:16] is the sub-list for method output_type - 16, // [16:16] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 11, // 5: compass.v1.AgentFrame.forge_notification_ack:type_name -> compass.v1.ForgeNotificationAck + 14, // 6: compass.v1.SessionFrame.state:type_name -> compass.v1.AgentSessionState + 15, // 7: compass.v1.SessionFrame.typed_event:type_name -> compass.v1.SessionEvent + 4, // 8: compass.v1.AgentControl.prompt:type_name -> compass.v1.PromptControl + 6, // 9: compass.v1.AgentControl.steer:type_name -> compass.v1.SteerControl + 9, // 10: compass.v1.AgentControl.deliver:type_name -> compass.v1.DeliverControl + 8, // 11: compass.v1.AgentControl.config:type_name -> compass.v1.ConfigControl + 7, // 12: compass.v1.AgentControl.replay:type_name -> compass.v1.TranscriptReplay + 5, // 13: compass.v1.AgentControl.replay_complete:type_name -> compass.v1.ReplayComplete + 16, // 14: compass.v1.AgentControl.forge_notification:type_name -> compass.v1.ForgeNotification + 17, // 15: compass.v1.SteerControl.message:type_name -> compass.v1.Message + 17, // 16: compass.v1.DeliverControl.message:type_name -> compass.v1.Message + 17, // [17:17] is the sub-list for method output_type + 17, // [17:17] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_compass_v1_agent_proto_init() } @@ -1143,6 +1229,7 @@ func file_compass_v1_agent_proto_init() { (*AgentFrame_ControlAck)(nil), (*AgentFrame_DeliveryAck)(nil), (*AgentFrame_TranscriptEntry)(nil), + (*AgentFrame_ForgeNotificationAck)(nil), } file_compass_v1_agent_proto_msgTypes[3].OneofWrappers = []any{ (*AgentControl_Prompt)(nil), @@ -1159,7 +1246,7 @@ func file_compass_v1_agent_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_compass_v1_agent_proto_rawDesc), len(file_compass_v1_agent_proto_rawDesc)), NumEnums: 0, - NumMessages: 13, + NumMessages: 14, NumExtensions: 0, NumServices: 0, }, diff --git a/go/internal/gen/compass/v1/agent_gateway.pb.go b/go/internal/gen/compass/v1/agent_gateway.pb.go index 0337cd5a0..5db47a28d 100644 --- a/go/internal/gen/compass/v1/agent_gateway.pb.go +++ b/go/internal/gen/compass/v1/agent_gateway.pb.go @@ -52,6 +52,62 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Subscribe/unsubscribe a forge artifact for change notifications (DL-053). The +// notification payload is ForgeNotification (forge.proto), delivered on the +// Sessions -> AgentGateway.Control push path. +// The subscription scope (W2, decided (b): the number=0 sentinel is dropped; +// OQ-1 ruled (i)). ARTIFACT addresses one issue/PR; CONTAINER addresses the +// whole repo on GitHub or a PROJECT on Linear. Zero is treated as ARTIFACT for +// pre-scope callers. +type ForgeSubscriptionScope int32 + +const ( + ForgeSubscriptionScope_FORGE_SUBSCRIPTION_SCOPE_UNSPECIFIED ForgeSubscriptionScope = 0 // treated as ARTIFACT (pre-scope callers) + ForgeSubscriptionScope_FORGE_SUBSCRIPTION_SCOPE_ARTIFACT ForgeSubscriptionScope = 1 // one issue/PR; number REQUIRED (> 0) + ForgeSubscriptionScope_FORGE_SUBSCRIPTION_SCOPE_CONTAINER ForgeSubscriptionScope = 2 // GitHub: the whole repo; Linear: a PROJECT +) + +// Enum value maps for ForgeSubscriptionScope. +var ( + ForgeSubscriptionScope_name = map[int32]string{ + 0: "FORGE_SUBSCRIPTION_SCOPE_UNSPECIFIED", + 1: "FORGE_SUBSCRIPTION_SCOPE_ARTIFACT", + 2: "FORGE_SUBSCRIPTION_SCOPE_CONTAINER", + } + ForgeSubscriptionScope_value = map[string]int32{ + "FORGE_SUBSCRIPTION_SCOPE_UNSPECIFIED": 0, + "FORGE_SUBSCRIPTION_SCOPE_ARTIFACT": 1, + "FORGE_SUBSCRIPTION_SCOPE_CONTAINER": 2, + } +) + +func (x ForgeSubscriptionScope) Enum() *ForgeSubscriptionScope { + p := new(ForgeSubscriptionScope) + *p = x + return p +} + +func (x ForgeSubscriptionScope) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ForgeSubscriptionScope) Descriptor() protoreflect.EnumDescriptor { + return file_compass_v1_agent_gateway_proto_enumTypes[0].Descriptor() +} + +func (ForgeSubscriptionScope) Type() protoreflect.EnumType { + return &file_compass_v1_agent_gateway_proto_enumTypes[0] +} + +func (x ForgeSubscriptionScope) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ForgeSubscriptionScope.Descriptor instead. +func (ForgeSubscriptionScope) EnumDescriptor() ([]byte, []int) { + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{0} +} + // One agent-initiated comms call. `call_id` is the agent-minted correlation id // (the SDK toolCallId); the `call` oneof selects the comms operation. The same // message is the `RelayCommsCallRequest.call` payload on the Runner->Server leg. @@ -2118,14 +2174,13 @@ func (x *ReviewCommentInput) GetBody() string { return "" } -// Subscribe/unsubscribe a forge artifact for change notifications (DL-053). The -// notification payload is ForgeNotification (forge.proto), delivered on the -// Sessions -> AgentGateway.Control push path. type SubscribeForgeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` + Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"` // GitHub owner/name; Linear team key Kind ForgeArtifactKind `protobuf:"varint,2,opt,name=kind,proto3,enum=compass.v1.ForgeArtifactKind" json:"kind,omitempty"` - Number uint64 `protobuf:"varint,3,opt,name=number,proto3" json:"number,omitempty"` + Number uint64 `protobuf:"varint,3,opt,name=number,proto3" json:"number,omitempty"` // ARTIFACT only; MUST be 0 under CONTAINER + Scope ForgeSubscriptionScope `protobuf:"varint,4,opt,name=scope,proto3,enum=compass.v1.ForgeSubscriptionScope" json:"scope,omitempty"` // additive; UNSPECIFIED = ARTIFACT + Project string `protobuf:"bytes,5,opt,name=project,proto3" json:"project,omitempty"` // CONTAINER on LINEAR only: the project id unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2181,6 +2236,20 @@ func (x *SubscribeForgeRequest) GetNumber() uint64 { return 0 } +func (x *SubscribeForgeRequest) GetScope() ForgeSubscriptionScope { + if x != nil { + return x.Scope + } + return ForgeSubscriptionScope_FORGE_SUBSCRIPTION_SCOPE_UNSPECIFIED +} + +func (x *SubscribeForgeRequest) GetProject() string { + if x != nil { + return x.Project + } + return "" +} + type SubscribeForgeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` SubscriptionId string `protobuf:"bytes,1,opt,name=subscription_id,json=subscriptionId,proto3" json:"subscription_id,omitempty"` @@ -2998,11 +3067,13 @@ const file_compass_v1_agent_gateway_proto_rawDesc = "" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + "\x04line\x18\x02 \x01(\rR\x04line\x12\x12\n" + "\x04side\x18\x03 \x01(\tR\x04side\x12\x12\n" + - "\x04body\x18\x04 \x01(\tR\x04body\"v\n" + + "\x04body\x18\x04 \x01(\tR\x04body\"\xca\x01\n" + "\x15SubscribeForgeRequest\x12\x12\n" + "\x04repo\x18\x01 \x01(\tR\x04repo\x121\n" + "\x04kind\x18\x02 \x01(\x0e2\x1d.compass.v1.ForgeArtifactKindR\x04kind\x12\x16\n" + - "\x06number\x18\x03 \x01(\x04R\x06number\"A\n" + + "\x06number\x18\x03 \x01(\x04R\x06number\x128\n" + + "\x05scope\x18\x04 \x01(\x0e2\".compass.v1.ForgeSubscriptionScopeR\x05scope\x12\x18\n" + + "\aproject\x18\x05 \x01(\tR\aproject\"A\n" + "\x16SubscribeForgeResponse\x12'\n" + "\x0fsubscription_id\x18\x01 \x01(\tR\x0esubscriptionId\"B\n" + "\x17UnsubscribeForgeRequest\x12'\n" + @@ -3032,7 +3103,11 @@ const file_compass_v1_agent_gateway_proto_rawDesc = "" + "\x05frame\x18\x01 \x01(\v2\x16.compass.v1.AgentFrameR\x05frame\x12'\n" + "\x0fidempotency_key\x18\x02 \x01(\tR\x0eidempotencyKey\"\x1f\n" + "\x1dPostConversationFrameResponse\"\x19\n" + - "\x17ControlSubscribeRequest2\xb4\x04\n" + + "\x17ControlSubscribeRequest*\x91\x01\n" + + "\x16ForgeSubscriptionScope\x12(\n" + + "$FORGE_SUBSCRIPTION_SCOPE_UNSPECIFIED\x10\x00\x12%\n" + + "!FORGE_SUBSCRIPTION_SCOPE_ARTIFACT\x10\x01\x12&\n" + + "\"FORGE_SUBSCRIPTION_SCOPE_CONTAINER\x10\x022\xb4\x04\n" + "\fAgentGateway\x12B\n" + "\x05Comms\x12\x1c.compass.v1.CommsCallRequest\x1a\x1b.compass.v1.CommsCallResult\x12N\n" + "\tLifecycle\x12 .compass.v1.LifecycleCallRequest\x1a\x1f.compass.v1.LifecycleCallResult\x12N\n" + @@ -3054,131 +3129,134 @@ func file_compass_v1_agent_gateway_proto_rawDescGZIP() []byte { return file_compass_v1_agent_gateway_proto_rawDescData } +var file_compass_v1_agent_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_compass_v1_agent_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 39) var file_compass_v1_agent_gateway_proto_goTypes = []any{ - (*CommsCallRequest)(nil), // 0: compass.v1.CommsCallRequest - (*CommsCallResult)(nil), // 1: compass.v1.CommsCallResult - (*CommsCallError)(nil), // 2: compass.v1.CommsCallError - (*SetAgentStatusRequest)(nil), // 3: compass.v1.SetAgentStatusRequest - (*SetAgentStatusResponse)(nil), // 4: compass.v1.SetAgentStatusResponse - (*LifecycleCallRequest)(nil), // 5: compass.v1.LifecycleCallRequest - (*SpawnPeerRequest)(nil), // 6: compass.v1.SpawnPeerRequest - (*SpawnPeerResponse)(nil), // 7: compass.v1.SpawnPeerResponse - (*DespawnPeerRequest)(nil), // 8: compass.v1.DespawnPeerRequest - (*DespawnPeerResponse)(nil), // 9: compass.v1.DespawnPeerResponse - (*LifecycleCallResult)(nil), // 10: compass.v1.LifecycleCallResult - (*LifecycleCallError)(nil), // 11: compass.v1.LifecycleCallError - (*ForgeCallRequest)(nil), // 12: compass.v1.ForgeCallRequest - (*ForgeCallResult)(nil), // 13: compass.v1.ForgeCallResult - (*ForgeCallError)(nil), // 14: compass.v1.ForgeCallError - (*CreateIssueRequest)(nil), // 15: compass.v1.CreateIssueRequest - (*CommentOnIssueRequest)(nil), // 16: compass.v1.CommentOnIssueRequest - (*GetIssueRequest)(nil), // 17: compass.v1.GetIssueRequest - (*ListIssuesRequest)(nil), // 18: compass.v1.ListIssuesRequest - (*ListIssuesResponse)(nil), // 19: compass.v1.ListIssuesResponse - (*CreatePullRequestRequest)(nil), // 20: compass.v1.CreatePullRequestRequest - (*CommentOnPullRequestRequest)(nil), // 21: compass.v1.CommentOnPullRequestRequest - (*GetPullRequestRequest)(nil), // 22: compass.v1.GetPullRequestRequest - (*SubmitReviewRequest)(nil), // 23: compass.v1.SubmitReviewRequest - (*ReviewCommentInput)(nil), // 24: compass.v1.ReviewCommentInput - (*SubscribeForgeRequest)(nil), // 25: compass.v1.SubscribeForgeRequest - (*SubscribeForgeResponse)(nil), // 26: compass.v1.SubscribeForgeResponse - (*UnsubscribeForgeRequest)(nil), // 27: compass.v1.UnsubscribeForgeRequest - (*UnsubscribeForgeResponse)(nil), // 28: compass.v1.UnsubscribeForgeResponse - (*BoardCallRequest)(nil), // 29: compass.v1.BoardCallRequest - (*SetIssueStateRequest)(nil), // 30: compass.v1.SetIssueStateRequest - (*SetIssueStateResponse)(nil), // 31: compass.v1.SetIssueStateResponse - (*BoardCallResult)(nil), // 32: compass.v1.BoardCallResult - (*BoardCallError)(nil), // 33: compass.v1.BoardCallError - (*PublishFrameRequest)(nil), // 34: compass.v1.PublishFrameRequest - (*PublishFrameResponse)(nil), // 35: compass.v1.PublishFrameResponse - (*PostConversationFrameRequest)(nil), // 36: compass.v1.PostConversationFrameRequest - (*PostConversationFrameResponse)(nil), // 37: compass.v1.PostConversationFrameResponse - (*ControlSubscribeRequest)(nil), // 38: compass.v1.ControlSubscribeRequest - (*v1.PostMessageRequest)(nil), // 39: compass.v1.PostMessageRequest - (*v1.ListMessagesRequest)(nil), // 40: compass.v1.ListMessagesRequest - (*v1.GetRosterRequest)(nil), // 41: compass.v1.GetRosterRequest - (*v1.UpdatePinnedBoardRequest)(nil), // 42: compass.v1.UpdatePinnedBoardRequest - (*v1.PostMessageResponse)(nil), // 43: compass.v1.PostMessageResponse - (*v1.ListMessagesResponse)(nil), // 44: compass.v1.ListMessagesResponse - (*v1.GetRosterResponse)(nil), // 45: compass.v1.GetRosterResponse - (*v1.UpdatePinnedBoardResponse)(nil), // 46: compass.v1.UpdatePinnedBoardResponse - (*v1.ForgeRef)(nil), // 47: compass.v1.ForgeRef - (*v1.Issue)(nil), // 48: compass.v1.Issue - (*CommentRef)(nil), // 49: compass.v1.CommentRef - (*v1.PullRequest)(nil), // 50: compass.v1.PullRequest - (*ReviewRef)(nil), // 51: compass.v1.ReviewRef - (ForgeArtifactKind)(0), // 52: compass.v1.ForgeArtifactKind - (v1.IssueState)(0), // 53: compass.v1.IssueState - (*AgentFrame)(nil), // 54: compass.v1.AgentFrame - (*AgentControl)(nil), // 55: compass.v1.AgentControl + (ForgeSubscriptionScope)(0), // 0: compass.v1.ForgeSubscriptionScope + (*CommsCallRequest)(nil), // 1: compass.v1.CommsCallRequest + (*CommsCallResult)(nil), // 2: compass.v1.CommsCallResult + (*CommsCallError)(nil), // 3: compass.v1.CommsCallError + (*SetAgentStatusRequest)(nil), // 4: compass.v1.SetAgentStatusRequest + (*SetAgentStatusResponse)(nil), // 5: compass.v1.SetAgentStatusResponse + (*LifecycleCallRequest)(nil), // 6: compass.v1.LifecycleCallRequest + (*SpawnPeerRequest)(nil), // 7: compass.v1.SpawnPeerRequest + (*SpawnPeerResponse)(nil), // 8: compass.v1.SpawnPeerResponse + (*DespawnPeerRequest)(nil), // 9: compass.v1.DespawnPeerRequest + (*DespawnPeerResponse)(nil), // 10: compass.v1.DespawnPeerResponse + (*LifecycleCallResult)(nil), // 11: compass.v1.LifecycleCallResult + (*LifecycleCallError)(nil), // 12: compass.v1.LifecycleCallError + (*ForgeCallRequest)(nil), // 13: compass.v1.ForgeCallRequest + (*ForgeCallResult)(nil), // 14: compass.v1.ForgeCallResult + (*ForgeCallError)(nil), // 15: compass.v1.ForgeCallError + (*CreateIssueRequest)(nil), // 16: compass.v1.CreateIssueRequest + (*CommentOnIssueRequest)(nil), // 17: compass.v1.CommentOnIssueRequest + (*GetIssueRequest)(nil), // 18: compass.v1.GetIssueRequest + (*ListIssuesRequest)(nil), // 19: compass.v1.ListIssuesRequest + (*ListIssuesResponse)(nil), // 20: compass.v1.ListIssuesResponse + (*CreatePullRequestRequest)(nil), // 21: compass.v1.CreatePullRequestRequest + (*CommentOnPullRequestRequest)(nil), // 22: compass.v1.CommentOnPullRequestRequest + (*GetPullRequestRequest)(nil), // 23: compass.v1.GetPullRequestRequest + (*SubmitReviewRequest)(nil), // 24: compass.v1.SubmitReviewRequest + (*ReviewCommentInput)(nil), // 25: compass.v1.ReviewCommentInput + (*SubscribeForgeRequest)(nil), // 26: compass.v1.SubscribeForgeRequest + (*SubscribeForgeResponse)(nil), // 27: compass.v1.SubscribeForgeResponse + (*UnsubscribeForgeRequest)(nil), // 28: compass.v1.UnsubscribeForgeRequest + (*UnsubscribeForgeResponse)(nil), // 29: compass.v1.UnsubscribeForgeResponse + (*BoardCallRequest)(nil), // 30: compass.v1.BoardCallRequest + (*SetIssueStateRequest)(nil), // 31: compass.v1.SetIssueStateRequest + (*SetIssueStateResponse)(nil), // 32: compass.v1.SetIssueStateResponse + (*BoardCallResult)(nil), // 33: compass.v1.BoardCallResult + (*BoardCallError)(nil), // 34: compass.v1.BoardCallError + (*PublishFrameRequest)(nil), // 35: compass.v1.PublishFrameRequest + (*PublishFrameResponse)(nil), // 36: compass.v1.PublishFrameResponse + (*PostConversationFrameRequest)(nil), // 37: compass.v1.PostConversationFrameRequest + (*PostConversationFrameResponse)(nil), // 38: compass.v1.PostConversationFrameResponse + (*ControlSubscribeRequest)(nil), // 39: compass.v1.ControlSubscribeRequest + (*v1.PostMessageRequest)(nil), // 40: compass.v1.PostMessageRequest + (*v1.ListMessagesRequest)(nil), // 41: compass.v1.ListMessagesRequest + (*v1.GetRosterRequest)(nil), // 42: compass.v1.GetRosterRequest + (*v1.UpdatePinnedBoardRequest)(nil), // 43: compass.v1.UpdatePinnedBoardRequest + (*v1.PostMessageResponse)(nil), // 44: compass.v1.PostMessageResponse + (*v1.ListMessagesResponse)(nil), // 45: compass.v1.ListMessagesResponse + (*v1.GetRosterResponse)(nil), // 46: compass.v1.GetRosterResponse + (*v1.UpdatePinnedBoardResponse)(nil), // 47: compass.v1.UpdatePinnedBoardResponse + (*v1.ForgeRef)(nil), // 48: compass.v1.ForgeRef + (*v1.Issue)(nil), // 49: compass.v1.Issue + (*CommentRef)(nil), // 50: compass.v1.CommentRef + (*v1.PullRequest)(nil), // 51: compass.v1.PullRequest + (*ReviewRef)(nil), // 52: compass.v1.ReviewRef + (ForgeArtifactKind)(0), // 53: compass.v1.ForgeArtifactKind + (v1.IssueState)(0), // 54: compass.v1.IssueState + (*AgentFrame)(nil), // 55: compass.v1.AgentFrame + (*AgentControl)(nil), // 56: compass.v1.AgentControl } var file_compass_v1_agent_gateway_proto_depIdxs = []int32{ - 39, // 0: compass.v1.CommsCallRequest.post:type_name -> compass.v1.PostMessageRequest - 40, // 1: compass.v1.CommsCallRequest.list:type_name -> compass.v1.ListMessagesRequest - 41, // 2: compass.v1.CommsCallRequest.roster:type_name -> compass.v1.GetRosterRequest - 3, // 3: compass.v1.CommsCallRequest.set_status:type_name -> compass.v1.SetAgentStatusRequest - 42, // 4: compass.v1.CommsCallRequest.pin:type_name -> compass.v1.UpdatePinnedBoardRequest - 43, // 5: compass.v1.CommsCallResult.post:type_name -> compass.v1.PostMessageResponse - 44, // 6: compass.v1.CommsCallResult.list:type_name -> compass.v1.ListMessagesResponse - 2, // 7: compass.v1.CommsCallResult.error:type_name -> compass.v1.CommsCallError - 45, // 8: compass.v1.CommsCallResult.roster:type_name -> compass.v1.GetRosterResponse - 4, // 9: compass.v1.CommsCallResult.set_status:type_name -> compass.v1.SetAgentStatusResponse - 46, // 10: compass.v1.CommsCallResult.pin:type_name -> compass.v1.UpdatePinnedBoardResponse - 6, // 11: compass.v1.LifecycleCallRequest.spawn:type_name -> compass.v1.SpawnPeerRequest - 8, // 12: compass.v1.LifecycleCallRequest.despawn:type_name -> compass.v1.DespawnPeerRequest - 7, // 13: compass.v1.LifecycleCallResult.spawn:type_name -> compass.v1.SpawnPeerResponse - 9, // 14: compass.v1.LifecycleCallResult.despawn:type_name -> compass.v1.DespawnPeerResponse - 11, // 15: compass.v1.LifecycleCallResult.error:type_name -> compass.v1.LifecycleCallError - 15, // 16: compass.v1.ForgeCallRequest.create_issue:type_name -> compass.v1.CreateIssueRequest - 16, // 17: compass.v1.ForgeCallRequest.comment_on_issue:type_name -> compass.v1.CommentOnIssueRequest - 17, // 18: compass.v1.ForgeCallRequest.get_issue:type_name -> compass.v1.GetIssueRequest - 18, // 19: compass.v1.ForgeCallRequest.list_issues:type_name -> compass.v1.ListIssuesRequest - 20, // 20: compass.v1.ForgeCallRequest.create_pull_request:type_name -> compass.v1.CreatePullRequestRequest - 21, // 21: compass.v1.ForgeCallRequest.comment_on_pull_request:type_name -> compass.v1.CommentOnPullRequestRequest - 22, // 22: compass.v1.ForgeCallRequest.get_pull_request:type_name -> compass.v1.GetPullRequestRequest - 25, // 23: compass.v1.ForgeCallRequest.subscribe:type_name -> compass.v1.SubscribeForgeRequest - 27, // 24: compass.v1.ForgeCallRequest.unsubscribe:type_name -> compass.v1.UnsubscribeForgeRequest - 23, // 25: compass.v1.ForgeCallRequest.submit_review:type_name -> compass.v1.SubmitReviewRequest - 47, // 26: compass.v1.ForgeCallRequest.forge:type_name -> compass.v1.ForgeRef - 48, // 27: compass.v1.ForgeCallResult.issue:type_name -> compass.v1.Issue - 49, // 28: compass.v1.ForgeCallResult.issue_comment:type_name -> compass.v1.CommentRef - 19, // 29: compass.v1.ForgeCallResult.issues:type_name -> compass.v1.ListIssuesResponse - 50, // 30: compass.v1.ForgeCallResult.pull_request:type_name -> compass.v1.PullRequest - 49, // 31: compass.v1.ForgeCallResult.pr_comment:type_name -> compass.v1.CommentRef - 26, // 32: compass.v1.ForgeCallResult.subscribed:type_name -> compass.v1.SubscribeForgeResponse - 28, // 33: compass.v1.ForgeCallResult.unsubscribed:type_name -> compass.v1.UnsubscribeForgeResponse - 14, // 34: compass.v1.ForgeCallResult.error:type_name -> compass.v1.ForgeCallError - 51, // 35: compass.v1.ForgeCallResult.review:type_name -> compass.v1.ReviewRef - 48, // 36: compass.v1.ListIssuesResponse.issues:type_name -> compass.v1.Issue - 24, // 37: compass.v1.SubmitReviewRequest.comments:type_name -> compass.v1.ReviewCommentInput - 52, // 38: compass.v1.SubscribeForgeRequest.kind:type_name -> compass.v1.ForgeArtifactKind - 30, // 39: compass.v1.BoardCallRequest.set_issue_state:type_name -> compass.v1.SetIssueStateRequest - 53, // 40: compass.v1.SetIssueStateRequest.state:type_name -> compass.v1.IssueState - 48, // 41: compass.v1.SetIssueStateResponse.issue:type_name -> compass.v1.Issue - 31, // 42: compass.v1.BoardCallResult.set_issue_state:type_name -> compass.v1.SetIssueStateResponse - 33, // 43: compass.v1.BoardCallResult.error:type_name -> compass.v1.BoardCallError - 54, // 44: compass.v1.PublishFrameRequest.frame:type_name -> compass.v1.AgentFrame - 54, // 45: compass.v1.PostConversationFrameRequest.frame:type_name -> compass.v1.AgentFrame - 0, // 46: compass.v1.AgentGateway.Comms:input_type -> compass.v1.CommsCallRequest - 5, // 47: compass.v1.AgentGateway.Lifecycle:input_type -> compass.v1.LifecycleCallRequest - 34, // 48: compass.v1.AgentGateway.Publish:input_type -> compass.v1.PublishFrameRequest - 36, // 49: compass.v1.AgentGateway.PostConversationFrame:input_type -> compass.v1.PostConversationFrameRequest - 38, // 50: compass.v1.AgentGateway.Control:input_type -> compass.v1.ControlSubscribeRequest - 12, // 51: compass.v1.AgentGateway.Forge:input_type -> compass.v1.ForgeCallRequest - 29, // 52: compass.v1.AgentGateway.Board:input_type -> compass.v1.BoardCallRequest - 1, // 53: compass.v1.AgentGateway.Comms:output_type -> compass.v1.CommsCallResult - 10, // 54: compass.v1.AgentGateway.Lifecycle:output_type -> compass.v1.LifecycleCallResult - 35, // 55: compass.v1.AgentGateway.Publish:output_type -> compass.v1.PublishFrameResponse - 37, // 56: compass.v1.AgentGateway.PostConversationFrame:output_type -> compass.v1.PostConversationFrameResponse - 55, // 57: compass.v1.AgentGateway.Control:output_type -> compass.v1.AgentControl - 13, // 58: compass.v1.AgentGateway.Forge:output_type -> compass.v1.ForgeCallResult - 32, // 59: compass.v1.AgentGateway.Board:output_type -> compass.v1.BoardCallResult - 53, // [53:60] is the sub-list for method output_type - 46, // [46:53] is the sub-list for method input_type - 46, // [46:46] is the sub-list for extension type_name - 46, // [46:46] is the sub-list for extension extendee - 0, // [0:46] is the sub-list for field type_name + 40, // 0: compass.v1.CommsCallRequest.post:type_name -> compass.v1.PostMessageRequest + 41, // 1: compass.v1.CommsCallRequest.list:type_name -> compass.v1.ListMessagesRequest + 42, // 2: compass.v1.CommsCallRequest.roster:type_name -> compass.v1.GetRosterRequest + 4, // 3: compass.v1.CommsCallRequest.set_status:type_name -> compass.v1.SetAgentStatusRequest + 43, // 4: compass.v1.CommsCallRequest.pin:type_name -> compass.v1.UpdatePinnedBoardRequest + 44, // 5: compass.v1.CommsCallResult.post:type_name -> compass.v1.PostMessageResponse + 45, // 6: compass.v1.CommsCallResult.list:type_name -> compass.v1.ListMessagesResponse + 3, // 7: compass.v1.CommsCallResult.error:type_name -> compass.v1.CommsCallError + 46, // 8: compass.v1.CommsCallResult.roster:type_name -> compass.v1.GetRosterResponse + 5, // 9: compass.v1.CommsCallResult.set_status:type_name -> compass.v1.SetAgentStatusResponse + 47, // 10: compass.v1.CommsCallResult.pin:type_name -> compass.v1.UpdatePinnedBoardResponse + 7, // 11: compass.v1.LifecycleCallRequest.spawn:type_name -> compass.v1.SpawnPeerRequest + 9, // 12: compass.v1.LifecycleCallRequest.despawn:type_name -> compass.v1.DespawnPeerRequest + 8, // 13: compass.v1.LifecycleCallResult.spawn:type_name -> compass.v1.SpawnPeerResponse + 10, // 14: compass.v1.LifecycleCallResult.despawn:type_name -> compass.v1.DespawnPeerResponse + 12, // 15: compass.v1.LifecycleCallResult.error:type_name -> compass.v1.LifecycleCallError + 16, // 16: compass.v1.ForgeCallRequest.create_issue:type_name -> compass.v1.CreateIssueRequest + 17, // 17: compass.v1.ForgeCallRequest.comment_on_issue:type_name -> compass.v1.CommentOnIssueRequest + 18, // 18: compass.v1.ForgeCallRequest.get_issue:type_name -> compass.v1.GetIssueRequest + 19, // 19: compass.v1.ForgeCallRequest.list_issues:type_name -> compass.v1.ListIssuesRequest + 21, // 20: compass.v1.ForgeCallRequest.create_pull_request:type_name -> compass.v1.CreatePullRequestRequest + 22, // 21: compass.v1.ForgeCallRequest.comment_on_pull_request:type_name -> compass.v1.CommentOnPullRequestRequest + 23, // 22: compass.v1.ForgeCallRequest.get_pull_request:type_name -> compass.v1.GetPullRequestRequest + 26, // 23: compass.v1.ForgeCallRequest.subscribe:type_name -> compass.v1.SubscribeForgeRequest + 28, // 24: compass.v1.ForgeCallRequest.unsubscribe:type_name -> compass.v1.UnsubscribeForgeRequest + 24, // 25: compass.v1.ForgeCallRequest.submit_review:type_name -> compass.v1.SubmitReviewRequest + 48, // 26: compass.v1.ForgeCallRequest.forge:type_name -> compass.v1.ForgeRef + 49, // 27: compass.v1.ForgeCallResult.issue:type_name -> compass.v1.Issue + 50, // 28: compass.v1.ForgeCallResult.issue_comment:type_name -> compass.v1.CommentRef + 20, // 29: compass.v1.ForgeCallResult.issues:type_name -> compass.v1.ListIssuesResponse + 51, // 30: compass.v1.ForgeCallResult.pull_request:type_name -> compass.v1.PullRequest + 50, // 31: compass.v1.ForgeCallResult.pr_comment:type_name -> compass.v1.CommentRef + 27, // 32: compass.v1.ForgeCallResult.subscribed:type_name -> compass.v1.SubscribeForgeResponse + 29, // 33: compass.v1.ForgeCallResult.unsubscribed:type_name -> compass.v1.UnsubscribeForgeResponse + 15, // 34: compass.v1.ForgeCallResult.error:type_name -> compass.v1.ForgeCallError + 52, // 35: compass.v1.ForgeCallResult.review:type_name -> compass.v1.ReviewRef + 49, // 36: compass.v1.ListIssuesResponse.issues:type_name -> compass.v1.Issue + 25, // 37: compass.v1.SubmitReviewRequest.comments:type_name -> compass.v1.ReviewCommentInput + 53, // 38: compass.v1.SubscribeForgeRequest.kind:type_name -> compass.v1.ForgeArtifactKind + 0, // 39: compass.v1.SubscribeForgeRequest.scope:type_name -> compass.v1.ForgeSubscriptionScope + 31, // 40: compass.v1.BoardCallRequest.set_issue_state:type_name -> compass.v1.SetIssueStateRequest + 54, // 41: compass.v1.SetIssueStateRequest.state:type_name -> compass.v1.IssueState + 49, // 42: compass.v1.SetIssueStateResponse.issue:type_name -> compass.v1.Issue + 32, // 43: compass.v1.BoardCallResult.set_issue_state:type_name -> compass.v1.SetIssueStateResponse + 34, // 44: compass.v1.BoardCallResult.error:type_name -> compass.v1.BoardCallError + 55, // 45: compass.v1.PublishFrameRequest.frame:type_name -> compass.v1.AgentFrame + 55, // 46: compass.v1.PostConversationFrameRequest.frame:type_name -> compass.v1.AgentFrame + 1, // 47: compass.v1.AgentGateway.Comms:input_type -> compass.v1.CommsCallRequest + 6, // 48: compass.v1.AgentGateway.Lifecycle:input_type -> compass.v1.LifecycleCallRequest + 35, // 49: compass.v1.AgentGateway.Publish:input_type -> compass.v1.PublishFrameRequest + 37, // 50: compass.v1.AgentGateway.PostConversationFrame:input_type -> compass.v1.PostConversationFrameRequest + 39, // 51: compass.v1.AgentGateway.Control:input_type -> compass.v1.ControlSubscribeRequest + 13, // 52: compass.v1.AgentGateway.Forge:input_type -> compass.v1.ForgeCallRequest + 30, // 53: compass.v1.AgentGateway.Board:input_type -> compass.v1.BoardCallRequest + 2, // 54: compass.v1.AgentGateway.Comms:output_type -> compass.v1.CommsCallResult + 11, // 55: compass.v1.AgentGateway.Lifecycle:output_type -> compass.v1.LifecycleCallResult + 36, // 56: compass.v1.AgentGateway.Publish:output_type -> compass.v1.PublishFrameResponse + 38, // 57: compass.v1.AgentGateway.PostConversationFrame:output_type -> compass.v1.PostConversationFrameResponse + 56, // 58: compass.v1.AgentGateway.Control:output_type -> compass.v1.AgentControl + 14, // 59: compass.v1.AgentGateway.Forge:output_type -> compass.v1.ForgeCallResult + 33, // 60: compass.v1.AgentGateway.Board:output_type -> compass.v1.BoardCallResult + 54, // [54:61] is the sub-list for method output_type + 47, // [47:54] is the sub-list for method input_type + 47, // [47:47] is the sub-list for extension type_name + 47, // [47:47] is the sub-list for extension extendee + 0, // [0:47] is the sub-list for field type_name } func init() { file_compass_v1_agent_gateway_proto_init() } @@ -3247,13 +3325,14 @@ func file_compass_v1_agent_gateway_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_compass_v1_agent_gateway_proto_rawDesc), len(file_compass_v1_agent_gateway_proto_rawDesc)), - NumEnums: 0, + NumEnums: 1, NumMessages: 39, NumExtensions: 0, NumServices: 1, }, GoTypes: file_compass_v1_agent_gateway_proto_goTypes, DependencyIndexes: file_compass_v1_agent_gateway_proto_depIdxs, + EnumInfos: file_compass_v1_agent_gateway_proto_enumTypes, MessageInfos: file_compass_v1_agent_gateway_proto_msgTypes, }.Build() File_compass_v1_agent_gateway_proto = out.File diff --git a/go/internal/gen/compass/v1/forge.pb.go b/go/internal/gen/compass/v1/forge.pb.go index 7c688fa39..ac9d6cb65 100644 --- a/go/internal/gen/compass/v1/forge.pb.go +++ b/go/internal/gen/compass/v1/forge.pb.go @@ -114,6 +114,9 @@ const ( ForgeNotificationKind_FORGE_NOTIFICATION_KIND_STATE ForgeNotificationKind = 2 // opened/closed/merged/reopened ForgeNotificationKind_FORGE_NOTIFICATION_KIND_UPDATE ForgeNotificationKind = 3 // title/body/labels edited ForgeNotificationKind_FORGE_NOTIFICATION_KIND_CHECKS ForgeNotificationKind = 4 // CI or status-check state changed + ForgeNotificationKind_FORGE_NOTIFICATION_KIND_REVIEW ForgeNotificationKind = 5 // a submitted PR review; comment carries + // body+url, state the verdict + ForgeNotificationKind_FORGE_NOTIFICATION_KIND_OPENED ForgeNotificationKind = 6 // container-scope: a new artifact; the ) // Enum value maps for ForgeNotificationKind. @@ -124,6 +127,8 @@ var ( 2: "FORGE_NOTIFICATION_KIND_STATE", 3: "FORGE_NOTIFICATION_KIND_UPDATE", 4: "FORGE_NOTIFICATION_KIND_CHECKS", + 5: "FORGE_NOTIFICATION_KIND_REVIEW", + 6: "FORGE_NOTIFICATION_KIND_OPENED", } ForgeNotificationKind_value = map[string]int32{ "FORGE_NOTIFICATION_KIND_UNSPECIFIED": 0, @@ -131,6 +136,8 @@ var ( "FORGE_NOTIFICATION_KIND_STATE": 2, "FORGE_NOTIFICATION_KIND_UPDATE": 3, "FORGE_NOTIFICATION_KIND_CHECKS": 4, + "FORGE_NOTIFICATION_KIND_REVIEW": 5, + "FORGE_NOTIFICATION_KIND_OPENED": 6, } ) @@ -330,7 +337,12 @@ type ForgeNotification struct { // Set for CHECKS: the rolled-up CI/status state after the change. Checks *v1.ChecksSummary `protobuf:"bytes,9,opt,name=checks,proto3" json:"checks,omitempty"` // Set for STATE: the new forge state string ("closed", "merged", …). - State string `protobuf:"bytes,10,opt,name=state,proto3" json:"state,omitempty"` + State string `protobuf:"bytes,10,opt,name=state,proto3" json:"state,omitempty"` + // The whole-artifact snapshot digest this notification reflects (T4's + // SnapshotRevision, computed at ApplyEvent). The agent echoes it back in + // ForgeNotificationAck.revision at turn-end flush; the Server advances the + // subscription's delivered_revision to it (two-cursor split, DL-053/DL-266). + Revision string `protobuf:"bytes,11,opt,name=revision,proto3" json:"revision,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -435,6 +447,13 @@ func (x *ForgeNotification) GetState() string { return "" } +func (x *ForgeNotification) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + var File_compass_v1_forge_proto protoreflect.FileDescriptor const file_compass_v1_forge_proto_rawDesc = "" + @@ -452,7 +471,7 @@ const file_compass_v1_forge_proto_rawDesc = "" + "\tReviewRef\x12\x10\n" + "\x03url\x18\x01 \x01(\tR\x03url\x12\x1b\n" + "\treview_id\x18\x02 \x01(\x04R\breviewId\x12\x18\n" + - "\averdict\x18\x03 \x01(\tR\averdict\"\x8f\x03\n" + + "\averdict\x18\x03 \x01(\tR\averdict\"\xab\x03\n" + "\x11ForgeNotification\x12'\n" + "\x0fsubscription_id\x18\x01 \x01(\tR\x0esubscriptionId\x12*\n" + "\x05forge\x18\x02 \x01(\v2\x14.compass.v1.ForgeRefR\x05forge\x12\x12\n" + @@ -464,17 +483,20 @@ const file_compass_v1_forge_proto_rawDesc = "" + "\acomment\x18\b \x01(\v2\x16.compass.v1.CommentRefR\acomment\x121\n" + "\x06checks\x18\t \x01(\v2\x19.compass.v1.ChecksSummaryR\x06checks\x12\x14\n" + "\x05state\x18\n" + - " \x01(\tR\x05state*}\n" + + " \x01(\tR\x05state\x12\x1a\n" + + "\brevision\x18\v \x01(\tR\brevision*}\n" + "\x11ForgeArtifactKind\x12#\n" + "\x1fFORGE_ARTIFACT_KIND_UNSPECIFIED\x10\x00\x12\x1d\n" + "\x19FORGE_ARTIFACT_KIND_ISSUE\x10\x01\x12$\n" + - " FORGE_ARTIFACT_KIND_PULL_REQUEST\x10\x02*\xd0\x01\n" + + " FORGE_ARTIFACT_KIND_PULL_REQUEST\x10\x02*\x98\x02\n" + "\x15ForgeNotificationKind\x12'\n" + "#FORGE_NOTIFICATION_KIND_UNSPECIFIED\x10\x00\x12#\n" + "\x1fFORGE_NOTIFICATION_KIND_COMMENT\x10\x01\x12!\n" + "\x1dFORGE_NOTIFICATION_KIND_STATE\x10\x02\x12\"\n" + "\x1eFORGE_NOTIFICATION_KIND_UPDATE\x10\x03\x12\"\n" + - "\x1eFORGE_NOTIFICATION_KIND_CHECKS\x10\x04b\x06proto3" + "\x1eFORGE_NOTIFICATION_KIND_CHECKS\x10\x04\x12\"\n" + + "\x1eFORGE_NOTIFICATION_KIND_REVIEW\x10\x05\x12\"\n" + + "\x1eFORGE_NOTIFICATION_KIND_OPENED\x10\x06b\x06proto3" var ( file_compass_v1_forge_proto_rawDescOnce sync.Once diff --git a/go/internal/linearagent/data_event.go b/go/internal/linearagent/data_event.go new file mode 100644 index 000000000..064aad584 --- /dev/null +++ b/go/internal/linearagent/data_event.go @@ -0,0 +1,188 @@ +package linearagent + +import ( + "encoding/json" + "fmt" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/forge" + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" +) + +// dataEvent models Linear's data-change webhook envelope (type Issue/Comment, +// action create/update/remove, with a `data` object and, on update, an +// `updatedFrom` object of previous values). It is the sibling of SessionEvent +// (webhook.go) — the AgentSession arm is untouched; this is a new data arm. +// json tags mirror Linear's camelCase payload keys. +type dataEvent struct { + Type string `json:"type"` + Action string `json:"action"` + Data dataPayload `json:"data"` + UpdatedFrom json.RawMessage `json:"updatedFrom"` +} + +// dataPayload is the union of Issue/Comment data fields this arm reads. +type dataPayload struct { + // Issue fields. + Number uint64 `json:"number"` + URL string `json:"url"` + Identifier string `json:"identifier"` + Team dataTeam `json:"team"` + State dataState `json:"state"` + ProjectID string `json:"projectId"` + Project dataProject `json:"project"` + + // Comment fields. + ID string `json:"id"` + Body string `json:"body"` + User dataUser `json:"user"` + Issue *dataIssue `json:"issue"` +} + +type dataTeam struct { + Key string `json:"key"` +} + +type dataState struct { + Type string `json:"type"` +} + +type dataProject struct { + ID string `json:"id"` +} + +type dataUser struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` +} + +// dataIssue is the issue a comment is attached to. +type dataIssue struct { + Number uint64 `json:"number"` + URL string `json:"url"` + Team dataTeam `json:"team"` + ProjectID string `json:"projectId"` + Project dataProject `json:"project"` +} + +// dataUpdatedFrom carries only the fields this arm inspects to discriminate a +// STATE change from a plain UPDATE. +type dataUpdatedFrom struct { + StateID *string `json:"stateId"` +} + +// ParseLinearDataEvent maps a raw Linear data-change webhook body to a +// normalized forge.ForgeEvent, or ok=false for a payload this arm ignores +// (counted-and-dropped by the caller, never an error). Mapping (design.md +// 660-666): Issue create->OPENED; Issue update->STATE iff updatedFrom shows a +// workflow-state change, else UPDATE; Comment create->COMMENT. `remove` +// actions are counted-and-dropped (no notification kind models deletion). +func ParseLinearDataEvent(raw []byte) (ev forge.ForgeEvent, ok bool, err error) { + var de dataEvent + if uerr := json.Unmarshal(raw, &de); uerr != nil { + return forge.ForgeEvent{}, false, fmt.Errorf("linearagent: parse data event: %w", uerr) + } + + switch de.Type { + case "Issue": + return parseLinearIssue(de) + case "Comment": + return parseLinearComment(de) + default: + return forge.ForgeEvent{}, false, nil + } +} + +func parseLinearIssue(de dataEvent) (forge.ForgeEvent, bool, error) { + base := forge.ForgeEvent{ + Provider: compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR, + Host: "linear.app", + Repo: de.Data.Team.Key, + Kind: compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_ISSUE, + Number: de.Data.Number, + Project: linearProjectID(de.Data.ProjectID, de.Data.Project), + URL: de.Data.URL, + } + + switch de.Action { + case "create": + base.Change = compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_OPENED + return base, true, nil + case "update": + if linearStateChanged(de.UpdatedFrom) { + base.Change = compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_STATE + base.State = forge.MapLinearState(de.Data.State.Type) + } else { + base.Change = compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_UPDATE + } + return base, true, nil + default: + // remove (and any other action) is counted-and-dropped. + return forge.ForgeEvent{}, false, nil + } +} + +func parseLinearComment(de dataEvent) (forge.ForgeEvent, bool, error) { + if de.Action != "create" || de.Data.Issue == nil { + return forge.ForgeEvent{}, false, nil + } + iss := de.Data.Issue + base := forge.ForgeEvent{ + Provider: compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR, + Host: "linear.app", + Repo: iss.Team.Key, + Kind: compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_ISSUE, + Number: iss.Number, + Project: linearProjectID(iss.ProjectID, iss.Project), + URL: iss.URL, + Change: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_COMMENT, + } + base.Comment = linearCommentRef(de.Data) + return base, true, nil +} + +// linearProjectID prefers the flat projectId, falling back to the nested +// project object's id. +func linearProjectID(projectID string, project dataProject) string { + if projectID != "" { + return projectID + } + return project.ID +} + +// linearStateChanged reports whether an update's updatedFrom names a prior +// stateId — i.e. the workflow state changed (design.md:660-662). +func linearStateChanged(updatedFrom json.RawMessage) bool { + if len(updatedFrom) == 0 { + return false + } + var uf dataUpdatedFrom + if err := json.Unmarshal(updatedFrom, &uf); err != nil { + return false + } + return uf.StateID != nil +} + +// linearCommentRef builds a CommentRef from a Linear comment, running +// forge.StripOwner over the body (the one strip point) and surfacing the agent +// claim only for a single well-formed header. +func linearCommentRef(d dataPayload) *compassv1internal.CommentRef { + clean, author, ok := forge.StripOwner(d.Body) + ref := &compassv1internal.CommentRef{ + Url: d.Issue.URL, + Body: clean, + ForgeAccount: linearAccount(d.User), + } + if ok { + ref.Agent = &compassv1.AgentAttribution{AgentHandle: author.AgentHandle} + } + return ref +} + +// linearAccount renders the commenter's display login, preferring displayName. +func linearAccount(u dataUser) string { + if u.DisplayName != "" { + return u.DisplayName + } + return u.Name +} diff --git a/go/internal/linearagent/data_event_test.go b/go/internal/linearagent/data_event_test.go new file mode 100644 index 000000000..b9a513122 --- /dev/null +++ b/go/internal/linearagent/data_event_test.go @@ -0,0 +1,155 @@ +package linearagent + +// Unit tests for the Linear data-change webhook arm: Issue/Comment payloads -> +// forge.ForgeEvent. Covers the T2 Linear test cycle (design.md:660-674): Issue +// create -> OPENED; Issue update -> STATE iff updatedFrom shows a workflow-state +// change, else UPDATE; Comment create -> COMMENT; the Issue payload's project id +// lands in Project; remove actions -> ok=false; StripOwner applied to bodies. + +import ( + "encoding/json" + "testing" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/forge" + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" +) + +func TestParseLinearDataEvent_Issue(t *testing.T) { + cases := []struct { + name string + body string + wantOK bool + wantChange compassv1internal.ForgeNotificationKind + wantState string + }{ + { + name: "create -> OPENED", + body: `{"type":"Issue","action":"create","data":{"number":42,"url":"iu","team":{"key":"SEA"},"projectId":"proj-1"}}`, + wantOK: true, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_OPENED, + }, + { + name: "update with state change -> STATE", + body: `{"type":"Issue","action":"update","data":{"number":42,"url":"iu","team":{"key":"SEA"},"state":{"type":"completed"}},"updatedFrom":{"stateId":"old-state"}}`, + wantOK: true, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_STATE, + wantState: "closed", + }, + { + name: "update without state change -> UPDATE", + body: `{"type":"Issue","action":"update","data":{"number":42,"url":"iu","team":{"key":"SEA"}},"updatedFrom":{"title":"old"}}`, + wantOK: true, + wantChange: compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_UPDATE, + }, + { + name: "remove -> dropped", + body: `{"type":"Issue","action":"remove","data":{"number":42,"team":{"key":"SEA"}}}`, + wantOK: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ev, ok, err := ParseLinearDataEvent([]byte(tc.body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if !ok { + return + } + if ev.Provider != compassv1.ForgeProvider_FORGE_PROVIDER_LINEAR { + t.Errorf("Provider = %v, want LINEAR", ev.Provider) + } + if ev.Host != "linear.app" { + t.Errorf("Host = %q, want linear.app", ev.Host) + } + if ev.Repo != "SEA" { + t.Errorf("Repo = %q, want SEA (team key)", ev.Repo) + } + if ev.Kind != compassv1internal.ForgeArtifactKind_FORGE_ARTIFACT_KIND_ISSUE { + t.Errorf("Kind = %v, want ISSUE", ev.Kind) + } + if ev.Number != 42 { + t.Errorf("Number = %d, want 42", ev.Number) + } + if ev.Change != tc.wantChange { + t.Errorf("Change = %v, want %v", ev.Change, tc.wantChange) + } + if ev.State != tc.wantState { + t.Errorf("State = %q, want %q", ev.State, tc.wantState) + } + }) + } +} + +// TestParseLinearDataEvent_ProjectID asserts an Issue payload's project id lands +// in ForgeEvent.Project (Linear container matching, W2; design.md:663-664). +func TestParseLinearDataEvent_ProjectID(t *testing.T) { + flat := `{"type":"Issue","action":"create","data":{"number":1,"team":{"key":"SEA"},"projectId":"proj-flat"}}` + nested := `{"type":"Issue","action":"create","data":{"number":1,"team":{"key":"SEA"},"project":{"id":"proj-nested"}}}` + + ev, _, err := ParseLinearDataEvent([]byte(flat)) + if err != nil { + t.Fatalf("flat: %v", err) + } + if ev.Project != "proj-flat" { + t.Errorf("flat Project = %q, want proj-flat", ev.Project) + } + + ev, _, err = ParseLinearDataEvent([]byte(nested)) + if err != nil { + t.Fatalf("nested: %v", err) + } + if ev.Project != "proj-nested" { + t.Errorf("nested Project = %q, want proj-nested", ev.Project) + } +} + +// TestParseLinearDataEvent_Comment asserts Comment create -> COMMENT with the +// body stripped through forge.StripOwner and the agent claim surfaced. +func TestParseLinearDataEvent_Comment(t *testing.T) { + stamped, err := forge.StampOwner("real body", forge.Author{AgentHandle: "agent-x", OwnerHandle: "owner-y", SessionID: "sess-1"}, 0) + if err != nil { + t.Fatalf("stamp: %v", err) + } + bodyBytes, err := json.Marshal(stamped) + if err != nil { + t.Fatalf("marshal: %v", err) + } + body := string(bodyBytes) + payload := `{"type":"Comment","action":"create","data":{"id":"c1","body":` + body + `,"user":{"displayName":"Alice"},"issue":{"number":7,"url":"iu","team":{"key":"SEA"},"projectId":"proj-1"}}}` + + ev, ok, err := ParseLinearDataEvent([]byte(payload)) + if err != nil || !ok { + t.Fatalf("parse: ok=%v err=%v", ok, err) + } + if ev.Change != compassv1internal.ForgeNotificationKind_FORGE_NOTIFICATION_KIND_COMMENT { + t.Errorf("Change = %v, want COMMENT", ev.Change) + } + if ev.Number != 7 { + t.Errorf("Number = %d, want 7 (issue number)", ev.Number) + } + if ev.Project != "proj-1" { + t.Errorf("Project = %q, want proj-1", ev.Project) + } + if ev.Comment == nil { + t.Fatal("Comment = nil, want set") + } + if ev.Comment.GetBody() != "real body" { + t.Errorf("body = %q, want %q", ev.Comment.GetBody(), "real body") + } + if ev.Comment.GetAgent().GetAgentHandle() != "agent-x" { + t.Errorf("agent claim = %q, want agent-x", ev.Comment.GetAgent().GetAgentHandle()) + } + if ev.Comment.GetForgeAccount() != "Alice" { + t.Errorf("forge_account = %q, want Alice", ev.Comment.GetForgeAccount()) + } + + // A non-create comment action is dropped. + if _, ok, _ := ParseLinearDataEvent([]byte(`{"type":"Comment","action":"update","data":{"id":"c1","issue":{"number":7}}}`)); ok { + t.Error("comment update ok = true, want false") + } +} diff --git a/go/internal/store/forge_subscriptions.go b/go/internal/store/forge_subscriptions.go index 8387b8d43..55ccba85e 100644 --- a/go/internal/store/forge_subscriptions.go +++ b/go/internal/store/forge_subscriptions.go @@ -13,11 +13,28 @@ import ( // Postgres row that records an agent's standing interest in one forge artifact. // agent_forge_subscriptions is the per-subscriber DELIVERY-cursor table // (delivered_revision/delivered_at); forge_artifact_cursors is the shared -// per-artifact FETCH cursor the poll driver writes. The GC invariant here is the -// only place this slice touches forge_artifact_cursors: when the LAST -// subscription for a coordinate is deleted, its cursor row is collected in the -// same transaction (DL-053). The poll driver owns the cursor WRITER (Piece 2); -// this file never inserts a cursor row. +// per-artifact FETCH cursor. This file owns two writers/readers of that shared +// table: UpsertForgeArtifactCursor (the FETCH-cursor WRITER — INSERT ... ON +// CONFLICT DO UPDATE, keyed by the coordinate PK) and ListForgeNotifyTargets +// (the notify-target READER — LEFT JOINs the cursor onto each subscribed +// coordinate). The GC invariant also lives here: when the LAST subscription for +// a coordinate is deleted, its cursor row is collected in the same transaction +// (DL-053). + +// ForgeSubscriptionScope mirrors compass.v1 ForgeSubscriptionScope +// (UNSPECIFIED=0, ARTIFACT=1, CONTAINER=2; RIG-2732 T3, OQ-1 ruled (i)). It +// discriminates a subscription to one issue/PR (ARTIFACT: number > 0) from a +// subscription to a whole container (CONTAINER: number == 0 — the whole repo on +// GitHub, a PROJECT on Linear). The wire UNSPECIFIED zero is treated as ARTIFACT +// for pre-scope callers; the store never persists 0 (the CHECK is scope IN +// (1, 2)), so the writer normalizes UNSPECIFIED -> ARTIFACT before insert. +type ForgeSubscriptionScope int32 + +const ( + ForgeSubscriptionScopeUnspecified ForgeSubscriptionScope = 0 + ForgeSubscriptionScopeArtifact ForgeSubscriptionScope = 1 + ForgeSubscriptionScopeContainer ForgeSubscriptionScope = 2 +) // AgentForgeSubscription is one row of agent_forge_subscriptions: an agent's // standing interest in one forge artifact coordinate, plus that subscriber's @@ -34,40 +51,85 @@ type AgentForgeSubscription struct { Kind ForgeArtifactKind // issue(1)/pull_request(2); never 0 Number uint64 + // Scope discriminates ARTIFACT (one issue/PR; Number > 0, Project empty) + // from CONTAINER (the whole repo on GitHub / a Linear PROJECT; Number == 0). + // The zero value (UNSPECIFIED) is normalized to ARTIFACT by the writer. + Scope ForgeSubscriptionScope + Project string // Linear CONTAINER rows: the project id; else "" + DeliveredRevision string DeliveredAt *time.Time CreatedAt time.Time } +// normalizeScope maps the wire UNSPECIFIED(0) zero to ARTIFACT — pre-scope +// callers subscribe to an artifact — so the store never persists scope 0 (the +// CHECK is scope IN (1, 2)). +func normalizeScope(scope ForgeSubscriptionScope) ForgeSubscriptionScope { + if scope == ForgeSubscriptionScopeUnspecified { + return ForgeSubscriptionScopeArtifact + } + return scope +} + // validSubscriptionCoordinate rejects the zero/empty coordinate fields // EnsureAgentForgeSubscription guards on before any DB round trip: the -// provider/host/repo triple (via validCoordinate), a zero kind (never -// UNSPECIFIED(0), the CHECK's job in Go space), and a zero artifact number. A -// caller bug is ErrInvalidArgument. -func validSubscriptionCoordinate(provider ForgeProvider, host, repo string, kind ForgeArtifactKind, number uint64) error { +// provider/host/repo triple (via validCoordinate) and a zero kind (never +// UNSPECIFIED(0), the CHECK's job in Go space) fail on EVERY scope arm. The +// scope discriminator then decides the number/project shape (RIG-2732 T3): +// - ARTIFACT: number REQUIRED (> 0) AND project MUST be empty (the W2 +// silent-misfire class — a zero number under ARTIFACT stays a caller bug). +// - CONTAINER: number MUST be 0 (the whole container, no artifact number); +// project REQUIRED on LINEAR (which container/project) and FORBIDDEN on any +// non-Linear forge (GitHub's container is the whole repo, no project id). +// +// A caller bug is ErrInvalidArgument. scope is taken pre-normalized so an +// explicit CONTAINER is distinguishable from the ARTIFACT default. +func validSubscriptionCoordinate(provider ForgeProvider, host, repo string, kind ForgeArtifactKind, number uint64, scope ForgeSubscriptionScope, project string) error { if err := validCoordinate(provider, host, repo); err != nil { return err } if kind != ForgeArtifactKindIssue && kind != ForgeArtifactKindPullRequest { return fmt.Errorf("%w: artifact kind must be issue or pull_request", ErrInvalidArgument) } - if number == 0 { - return fmt.Errorf("%w: artifact number is required", ErrInvalidArgument) + switch normalizeScope(scope) { + case ForgeSubscriptionScopeArtifact: + if number == 0 { + return fmt.Errorf("%w: artifact number is required", ErrInvalidArgument) + } + if project != "" { + return fmt.Errorf("%w: project must be empty for an artifact subscription", ErrInvalidArgument) + } + case ForgeSubscriptionScopeContainer: + if number != 0 { + return fmt.Errorf("%w: number must be 0 for a container subscription", ErrInvalidArgument) + } + if provider == ForgeProviderLinear { + if project == "" { + return fmt.Errorf("%w: project is required for a Linear container subscription", ErrInvalidArgument) + } + } else if project != "" { + return fmt.Errorf("%w: project is forbidden for a non-Linear container subscription", ErrInvalidArgument) + } + default: + return fmt.Errorf("%w: unknown subscription scope %d", ErrInvalidArgument, scope) } return nil } // EnsureAgentForgeSubscription idempotently inserts the agent's subscription to -// one artifact coordinate, keyed by the UNIQUE (agent_account_id, provider, -// host, repo, kind, number). A repeat subscribe by the same agent to the same -// artifact returns the EXISTING subscription id and creates no duplicate row — -// the DO UPDATE is a no-op touch (re-setting agent_account_id to itself) that -// makes RETURNING fire on the conflict path so a repeat returns the stored id, -// not a fresh one. A new coordinate mints a fresh id via newID(). Zero/empty -// coordinate fields / a zero kind / a zero number -> ErrInvalidArgument; an -// unknown agent (the FK RESTRICT) -> ErrInvalidArgument. +// one coordinate, keyed by the UNIQUE (agent_account_id, provider, host, repo, +// kind, number, project). A repeat subscribe by the same agent to the same +// artifact/container returns the EXISTING subscription id and creates no +// duplicate row — the DO UPDATE is a no-op touch (re-setting agent_account_id to +// itself) that makes RETURNING fire on the conflict path so a repeat returns the +// stored id, not a fresh one. A new coordinate mints a fresh id via newID(). +// scope is normalized (UNSPECIFIED -> ARTIFACT) before insert so the CHECK +// (scope IN (1,2)) always holds; the guard enforces the number/project shape per +// scope. Zero/empty coordinate fields / a zero kind / a scope-shape violation -> +// ErrInvalidArgument; an unknown agent (the FK RESTRICT) -> ErrInvalidArgument. func (s *Store) EnsureAgentForgeSubscription(ctx context.Context, sub AgentForgeSubscription) (string, error) { - if err := validSubscriptionCoordinate(sub.Provider, sub.Host, sub.Repo, sub.Kind, sub.Number); err != nil { + if err := validSubscriptionCoordinate(sub.Provider, sub.Host, sub.Repo, sub.Kind, sub.Number, sub.Scope, sub.Project); err != nil { return "", err } if sub.AgentAccountID == "" { @@ -76,13 +138,14 @@ func (s *Store) EnsureAgentForgeSubscription(ctx context.Context, sub AgentForge var id string if err := s.pool.QueryRow(ctx, `INSERT INTO agent_forge_subscriptions - (id, agent_account_id, forge_provider, forge_host, repo, kind, number) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (agent_account_id, forge_provider, forge_host, repo, kind, number) DO UPDATE + (id, agent_account_id, forge_provider, forge_host, repo, kind, number, scope, project) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (agent_account_id, forge_provider, forge_host, repo, kind, number, project) DO UPDATE SET agent_account_id = EXCLUDED.agent_account_id RETURNING id`, newID(), string(sub.AgentAccountID), int32(sub.Provider), sub.Host, sub.Repo, - int32(sub.Kind), int64(sub.Number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number) written to a BIGINT, always well within the int64 domain. + int32(sub.Kind), int64(sub.Number), //nolint:gosec // G115: number is a canonical forge artifact number (a positive issue/PR number, or 0 for a container) written to a BIGINT, always well within the int64 domain. + int32(normalizeScope(sub.Scope)), sub.Project, ).Scan(&id); err != nil { if pgErrIs(err, pgForeignKeyViolation) { return "", fmt.Errorf("%w: unknown agent %q", ErrInvalidArgument, sub.AgentAccountID) @@ -151,7 +214,7 @@ func (s *Store) DeleteAgentForgeSubscription(ctx context.Context, agent AccountI // subscriber-enumeration reader (Piece 2); it returns only the row count. // Zero/empty coordinate fields / a zero kind / a zero number -> ErrInvalidArgument. func (s *Store) AgentForgeSubscriptionsForArtifact(ctx context.Context, provider ForgeProvider, host, repo string, kind ForgeArtifactKind, number uint64) (int, error) { - if err := validSubscriptionCoordinate(provider, host, repo, kind, number); err != nil { + if err := validSubscriptionCoordinate(provider, host, repo, kind, number, ForgeSubscriptionScopeArtifact, ""); err != nil { return 0, err } var n int @@ -164,3 +227,278 @@ func (s *Store) AgentForgeSubscriptionsForArtifact(ctx context.Context, provider } return n, nil } + +// ForgeNotifySubscriber is one subscriber the notify path fans a change out to: +// the subscription id (the ack correlation key), the owning agent, that +// subscriber's last-notified DeliveredRevision (the router suppresses a +// re-notify when the change's revision equals it), and — for a collapsed +// container target whose subscribers span multiple Linear projects — the +// subscriber's own Project, so the router matches a project-P change to only +// its project-P subscribers ("" for artifact/GitHub subs). Struct shape frozen +// by the design record (RIG-2732 T3, §ListForgeNotifyTargets). +type ForgeNotifySubscriber struct { + SubscriptionID string + AgentAccountID AccountID + DeliveredRevision string + Project string +} + +// ForgeArtifactCursor is one row of forge_artifact_cursors: the shared +// per-artifact FETCH cursor (conditional-GET ETags + the last observed snapshot +// + its revision digest). Number == 0 is the container-scope reconcile cursor +// row (one per (repo, kind); the table admits number=0, project-less). Snapshot +// is the raw JSONB (nil when never stored). UpsertForgeArtifactCursor (this +// file) is the writer of these rows. +type ForgeArtifactCursor struct { + Provider ForgeProvider + Host, Repo string + Kind ForgeArtifactKind + Number uint64 // 0 = the container-scope reconcile cursor row + ETag string + CommentsETag, ChecksETag string + Revision string + Snapshot []byte // raw JSONB + PolledAt time.Time +} + +// ForgeNotifyTarget is one enumerated poll/reconcile coordinate: the artifact +// (or collapsed container) coordinate, its shared FETCH cursor (nil when never +// observed), and the subscribers riding it. Container targets collapse per +// (repo, kind) to Number == 0. +type ForgeNotifyTarget struct { + Provider ForgeProvider + Host, Repo string + Kind ForgeArtifactKind + Number uint64 + Cursor *ForgeArtifactCursor // nil: never observed + Subscribers []ForgeNotifySubscriber +} + +// SubscribersForArtifact returns the subscribers a change on one artifact must +// fan out to: the exact-artifact subscribers (scope ARTIFACT, matching number) +// and — when openedEvent (a newly OPENED artifact) — the container-scope +// subscribers for the same container. A container matches by (repo, kind) plus +// project: GitHub containers are project-less (project ”), so a GitHub artifact +// event passes project "" and matches the ” container rows; a Linear artifact +// event passes the artifact's project and matches only that project's container +// rows. One indexed query over agent_forge_subscriptions_artifact_idx (its +// leading (forge_provider, forge_host, repo, kind) columns). number MUST be > 0 +// (an artifact event always names an artifact); zero provider/kind/empty repo -> +// ErrInvalidArgument. +func (s *Store) SubscribersForArtifact(ctx context.Context, provider ForgeProvider, host, repo string, kind ForgeArtifactKind, number uint64, project string, openedEvent bool) ([]ForgeNotifySubscriber, error) { + if err := validCoordinate(provider, host, repo); err != nil { + return nil, err + } + if kind != ForgeArtifactKindIssue && kind != ForgeArtifactKindPullRequest { + return nil, fmt.Errorf("%w: artifact kind must be issue or pull_request", ErrInvalidArgument) + } + if number == 0 { + return nil, fmt.Errorf("%w: artifact number is required", ErrInvalidArgument) + } + rows, err := s.pool.Query(ctx, + `SELECT id, agent_account_id, delivered_revision, project + FROM agent_forge_subscriptions + WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 + AND ( + (scope = 1 AND number = $5) + OR ($6 AND scope = 2 AND number = 0 AND project = $7) + )`, + int32(provider), host, repo, int32(kind), + int64(number), //nolint:gosec // G115: canonical artifact number in a BIGINT domain. + openedEvent, project, + ) + if err != nil { + return nil, fmt.Errorf("store: subscribers for artifact: %w", err) + } + defer rows.Close() + var out []ForgeNotifySubscriber + for rows.Next() { + var sub ForgeNotifySubscriber + var agent string + if err := rows.Scan(&sub.SubscriptionID, &agent, &sub.DeliveredRevision, &sub.Project); err != nil { + return nil, fmt.Errorf("store: scan artifact subscriber: %w", err) + } + sub.AgentAccountID = AccountID(agent) + out = append(out, sub) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate artifact subscribers: %w", err) + } + return out, nil +} + +// ListForgeNotifyTargets enumerates the distinct subscribed coordinates for one +// (provider, host) — the reconcile sweep's work list — each with its shared +// FETCH cursor (LEFT JOIN forge_artifact_cursors; nil when never observed) and +// the subscribers riding it. Container-scope rows collapse per (repo, kind) to a +// single Number == 0 target: every Linear project sub on one team shares one +// team-keyed LIST walk, so they fold into one target whose Subscribers hold all +// the container subs for that (repo, kind). Zero provider / empty host -> +// ErrInvalidArgument. +func (s *Store) ListForgeNotifyTargets(ctx context.Context, provider ForgeProvider, host string) ([]ForgeNotifyTarget, error) { + if provider == ForgeProviderUnspecified { + return nil, fmt.Errorf("%w: forge provider is required", ErrInvalidArgument) + } + if host == "" { + return nil, fmt.Errorf("%w: forge host is required", ErrInvalidArgument) + } + rows, err := s.pool.Query(ctx, + `SELECT s.repo, s.kind, + CASE WHEN s.scope = 2 THEN 0 ELSE s.number END AS coord_number, + s.id, s.agent_account_id, s.delivered_revision, s.project, + c.forge_provider IS NOT NULL AS has_cursor, + c.etag, c.comments_etag, c.checks_etag, c.revision, c.snapshot, c.polled_at + FROM agent_forge_subscriptions s + LEFT JOIN forge_artifact_cursors c + ON c.forge_provider = s.forge_provider + AND c.forge_host = s.forge_host + AND c.repo = s.repo + AND c.kind = s.kind + AND c.number = CASE WHEN s.scope = 2 THEN 0 ELSE s.number END + WHERE s.forge_provider = $1 AND s.forge_host = $2 + ORDER BY s.repo, s.kind, coord_number`, + int32(provider), host, + ) + if err != nil { + return nil, fmt.Errorf("store: list forge notify targets: %w", err) + } + defer rows.Close() + var ( + out []ForgeNotifyTarget + cur *ForgeNotifyTarget // the target the current run of rows belongs to + ) + for rows.Next() { + var ( + repo string + kind int32 + coordNumber int64 + subID string + agent string + delivered string + project string + hasCursor bool + etag *string + commentsETag *string + checksETag *string + revision *string + snapshot []byte + polledAt *time.Time + ) + if err := rows.Scan(&repo, &kind, &coordNumber, &subID, &agent, &delivered, &project, + &hasCursor, &etag, &commentsETag, &checksETag, &revision, &snapshot, &polledAt); err != nil { + return nil, fmt.Errorf("store: scan forge notify target: %w", err) + } + // coord_number is a canonical artifact number (or 0) from a BIGINT, + // always within the uint64 domain — cast once, reuse for the coordinate + // compare and both target/cursor constructs. + coord := uint64(coordNumber) //nolint:gosec // G115: see above. + if cur == nil || cur.Repo != repo || int32(cur.Kind) != kind || cur.Number != coord { + out = append(out, ForgeNotifyTarget{ + Provider: provider, + Host: host, + Repo: repo, + Kind: ForgeArtifactKind(kind), + Number: coord, + }) + cur = &out[len(out)-1] + if hasCursor { + cur.Cursor = &ForgeArtifactCursor{ + Provider: provider, + Host: host, + Repo: repo, + Kind: ForgeArtifactKind(kind), + Number: coord, + ETag: derefString(etag), + CommentsETag: derefString(commentsETag), + ChecksETag: derefString(checksETag), + Revision: derefString(revision), + Snapshot: snapshot, + } + if polledAt != nil { + cur.Cursor.PolledAt = *polledAt + } + } + } + cur.Subscribers = append(cur.Subscribers, ForgeNotifySubscriber{ + SubscriptionID: subID, + AgentAccountID: AccountID(agent), + DeliveredRevision: delivered, + Project: project, + }) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate forge notify targets: %w", err) + } + return out, nil +} + +func derefString(p *string) string { + if p == nil { + return "" + } + return *p +} + +// UpsertForgeArtifactCursor writes (inserts or replaces) the shared per-artifact +// FETCH cursor at cur's coordinate, keyed by the PK (provider, host, repo, kind, +// number). number == 0 is the legal container-scope reconcile cursor row (the PK +// admits it). polled_at records this write; a zero cur.PolledAt defaults to now. +// Zero provider / empty host/repo / a zero kind -> ErrInvalidArgument. +func (s *Store) UpsertForgeArtifactCursor(ctx context.Context, cur ForgeArtifactCursor) error { + if err := validCoordinate(cur.Provider, cur.Host, cur.Repo); err != nil { + return err + } + if cur.Kind != ForgeArtifactKindIssue && cur.Kind != ForgeArtifactKindPullRequest { + return fmt.Errorf("%w: artifact kind must be issue or pull_request", ErrInvalidArgument) + } + polledAt := cur.PolledAt + if polledAt.IsZero() { + polledAt = time.Now().UTC() + } + if _, err := s.pool.Exec(ctx, + `INSERT INTO forge_artifact_cursors + (forge_provider, forge_host, repo, kind, number, etag, comments_etag, checks_etag, revision, snapshot, polled_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (forge_provider, forge_host, repo, kind, number) DO UPDATE + SET etag = EXCLUDED.etag, + comments_etag = EXCLUDED.comments_etag, + checks_etag = EXCLUDED.checks_etag, + revision = EXCLUDED.revision, + snapshot = EXCLUDED.snapshot, + polled_at = EXCLUDED.polled_at`, + int32(cur.Provider), cur.Host, cur.Repo, int32(cur.Kind), + int64(cur.Number), //nolint:gosec // G115: canonical artifact number (or 0 container) in a BIGINT domain. + cur.ETag, cur.CommentsETag, cur.ChecksETag, cur.Revision, cur.Snapshot, polledAt, + ); err != nil { + return fmt.Errorf("store: upsert forge artifact cursor: %w", err) + } + return nil +} + +// AdvanceForgeDeliveredRevision advances one subscription's per-subscriber +// DELIVERY cursor to revision, scoped to the owning agent (id AND +// agent_account_id must both match — an agent cannot advance another's cursor). +// Called from the hub's ForgeNotificationAck arm (W3; T7), never from the +// router's dispatch path. Zero rows (unknown id, or an id owned by a different +// agent, or unsubscribed mid-flight) -> ErrNotFound (log and move on). +func (s *Store) AdvanceForgeDeliveredRevision(ctx context.Context, agent AccountID, subscriptionID, revision string) error { + if agent == "" { + return fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) + } + if subscriptionID == "" { + return fmt.Errorf("%w: subscription id is required", ErrInvalidArgument) + } + tag, err := s.pool.Exec(ctx, + `UPDATE agent_forge_subscriptions + SET delivered_revision = $3, delivered_at = now() + WHERE id = $2 AND agent_account_id = $1`, + string(agent), subscriptionID, revision, + ) + if err != nil { + return fmt.Errorf("store: advance forge delivered revision: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("%w: subscription %q", ErrNotFound, subscriptionID) + } + return nil +} diff --git a/go/internal/store/forge_subscriptions_pgtest_test.go b/go/internal/store/forge_subscriptions_pgtest_test.go index 94c11dc39..b9ad036b5 100644 --- a/go/internal/store/forge_subscriptions_pgtest_test.go +++ b/go/internal/store/forge_subscriptions_pgtest_test.go @@ -255,3 +255,496 @@ func TestAgentForgeSubscriptionUnknownAgentIsInvalidArgument(t *testing.T) { }) sentinelIs(t, err, ErrInvalidArgument, "unknown agent (FK violation)") } + +// ── T3: container-scope subscriptions ───────────────────────────────────────── + +// containerSubCount counts subscription rows at a container coordinate +// (scope=2, number=0) for a given project — the T3 assertion surface. +func containerSubCount(t *testing.T, s *Store, provider ForgeProvider, host, repo string, kind ForgeArtifactKind, project string) int { + t.Helper() + var n int + if err := s.pool.QueryRow(context.Background(), + `SELECT count(*) FROM agent_forge_subscriptions + WHERE forge_provider = $1 AND forge_host = $2 AND repo = $3 AND kind = $4 + AND scope = 2 AND number = 0 AND project = $5`, + int32(provider), host, repo, int32(kind), project, + ).Scan(&n); err != nil { + t.Fatalf("count container subs: %v", err) + } + return n +} + +// TestAgentForgeContainerSubscriptionIdempotent: a GitHub CONTAINER subscribe is +// idempotent on (agent, repo, kind, 0, project=”) — a repeat returns the same +// id and adds no row. +func TestAgentForgeContainerSubscriptionIdempotent(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, _ := seedAgent(t, s, "t3-ctr-idem") + + base := AgentForgeSubscription{ + AgentAccountID: agent, + Provider: ForgeProviderGitHub, + Host: "github.com", + Repo: "a/b", + Kind: ForgeArtifactKindIssue, + Scope: ForgeSubscriptionScopeContainer, + } + first, err := s.EnsureAgentForgeSubscription(ctx, base) + if err != nil { + t.Fatalf("first container ensure: %v", err) + } + second, err := s.EnsureAgentForgeSubscription(ctx, base) + if err != nil { + t.Fatalf("second container ensure: %v", err) + } + if second != first { + t.Fatalf("repeat container subscribe id = %q, want %q (idempotent)", second, first) + } + if n := containerSubCount(t, s, base.Provider, base.Host, base.Repo, base.Kind, ""); n != 1 { + t.Fatalf("container row count = %d, want 1 (no duplicate)", n) + } +} + +// TestAgentForgeSubscriptionScopeValidation: the scope-shape guard rejects each +// mismatched (scope, number, project) combination with ErrInvalidArgument. +func TestAgentForgeSubscriptionScopeValidation(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, _ := seedAgent(t, s, "t3-scope-valid") + + cases := []struct { + name string + sub AgentForgeSubscription + }{ + {"artifact with number=0", AgentForgeSubscription{ + AgentAccountID: agent, Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Scope: ForgeSubscriptionScopeArtifact, Number: 0, + }}, + {"artifact with project set", AgentForgeSubscription{ + AgentAccountID: agent, Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Scope: ForgeSubscriptionScopeArtifact, Number: 5, Project: "P1", + }}, + {"container with number set", AgentForgeSubscription{ + AgentAccountID: agent, Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Scope: ForgeSubscriptionScopeContainer, Number: 3, + }}, + {"linear container without project", AgentForgeSubscription{ + AgentAccountID: agent, Provider: ForgeProviderLinear, Host: "linear.app", Repo: "TEAM", + Kind: ForgeArtifactKindIssue, Scope: ForgeSubscriptionScopeContainer, + }}, + {"github container with project", AgentForgeSubscription{ + AgentAccountID: agent, Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Scope: ForgeSubscriptionScopeContainer, Project: "P1", + }}, + {"unknown scope", AgentForgeSubscription{ + AgentAccountID: agent, Provider: ForgeProviderGitHub, Host: "github.com", Repo: "a/b", + Kind: ForgeArtifactKindIssue, Scope: ForgeSubscriptionScope(99), Number: 5, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := s.EnsureAgentForgeSubscription(ctx, tc.sub) + sentinelIs(t, err, ErrInvalidArgument, tc.name) + }) + } +} + +// TestAgentForgeLinearProjectContainersDistinct: two Linear project containers +// on one team (same agent, repo, kind; different project) coexist as two rows — +// the project column in the widened UNIQUE keeps them from colliding. +func TestAgentForgeLinearProjectContainersDistinct(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, _ := seedAgent(t, s, "t3-linear-proj") + + base := AgentForgeSubscription{ + AgentAccountID: agent, Provider: ForgeProviderLinear, Host: "linear.app", Repo: "TEAM", + Kind: ForgeArtifactKindIssue, Scope: ForgeSubscriptionScopeContainer, + } + p1 := base + p1.Project = "proj-1" + p2 := base + p2.Project = "proj-2" + + id1, err := s.EnsureAgentForgeSubscription(ctx, p1) + if err != nil { + t.Fatalf("ensure proj-1: %v", err) + } + id2, err := s.EnsureAgentForgeSubscription(ctx, p2) + if err != nil { + t.Fatalf("ensure proj-2: %v", err) + } + if id1 == id2 { + t.Fatalf("two project containers share id %q, want distinct", id1) + } + if n := containerSubCount(t, s, base.Provider, base.Host, base.Repo, base.Kind, "proj-1"); n != 1 { + t.Fatalf("proj-1 container count = %d, want 1", n) + } + if n := containerSubCount(t, s, base.Provider, base.Host, base.Repo, base.Kind, "proj-2"); n != 1 { + t.Fatalf("proj-2 container count = %d, want 1", n) + } +} + +// ── T3: SubscribersForArtifact — exact + container fan-out ──────────────────── + +// TestSubscribersForArtifactGitHub: an artifact event returns the exact-artifact +// subscriber always, and the GitHub container subscriber only when openedEvent. +func TestSubscribersForArtifactGitHub(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + exact, _ := seedAgent(t, s, "t3-sfa-exact") + ctr, _ := seedAgent(t, s, "t3-sfa-ctr") + + const ( + host = "github.com" + repo = "a/b" + number = uint64(42) + ) + provider := ForgeProviderGitHub + kind := ForgeArtifactKindIssue + + if _, err := s.EnsureAgentForgeSubscription(ctx, AgentForgeSubscription{ + AgentAccountID: exact, Provider: provider, Host: host, Repo: repo, Kind: kind, + Number: number, Scope: ForgeSubscriptionScopeArtifact, + }); err != nil { + t.Fatalf("ensure exact: %v", err) + } + if _, err := s.EnsureAgentForgeSubscription(ctx, AgentForgeSubscription{ + AgentAccountID: ctr, Provider: provider, Host: host, Repo: repo, Kind: kind, + Scope: ForgeSubscriptionScopeContainer, + }); err != nil { + t.Fatalf("ensure container: %v", err) + } + + // Non-opened event: only the exact-artifact subscriber. + subs, err := s.SubscribersForArtifact(ctx, provider, host, repo, kind, number, "", false) + if err != nil { + t.Fatalf("SubscribersForArtifact (not opened): %v", err) + } + if len(subs) != 1 || subs[0].AgentAccountID != exact { + t.Fatalf("not-opened subs = %+v, want just exact agent %q", subs, exact) + } + + // Opened event: exact + container. + subs, err = s.SubscribersForArtifact(ctx, provider, host, repo, kind, number, "", true) + if err != nil { + t.Fatalf("SubscribersForArtifact (opened): %v", err) + } + got := map[AccountID]bool{} + for _, sub := range subs { + got[sub.AgentAccountID] = true + } + if len(subs) != 2 || !got[exact] || !got[ctr] { + t.Fatalf("opened subs = %+v, want exact %q + container %q", subs, exact, ctr) + } +} + +// TestSubscribersForArtifactLinearProjectMatch: a Linear opened event fans out +// to the container subscriber whose project matches the artifact's project, and +// NOT to a container subscriber on a different project. +func TestSubscribersForArtifactLinearProjectMatch(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + match, _ := seedAgent(t, s, "t3-sfa-match") + miss, _ := seedAgent(t, s, "t3-sfa-miss") + + const ( + host = "linear.app" + repo = "TEAM" + number = uint64(7) + ) + provider := ForgeProviderLinear + kind := ForgeArtifactKindIssue + + if _, err := s.EnsureAgentForgeSubscription(ctx, AgentForgeSubscription{ + AgentAccountID: match, Provider: provider, Host: host, Repo: repo, Kind: kind, + Scope: ForgeSubscriptionScopeContainer, Project: "proj-A", + }); err != nil { + t.Fatalf("ensure match container: %v", err) + } + if _, err := s.EnsureAgentForgeSubscription(ctx, AgentForgeSubscription{ + AgentAccountID: miss, Provider: provider, Host: host, Repo: repo, Kind: kind, + Scope: ForgeSubscriptionScopeContainer, Project: "proj-B", + }); err != nil { + t.Fatalf("ensure miss container: %v", err) + } + + // Opened event on an artifact in proj-A: only the proj-A container subscriber. + subs, err := s.SubscribersForArtifact(ctx, provider, host, repo, kind, number, "proj-A", true) + if err != nil { + t.Fatalf("SubscribersForArtifact (linear opened): %v", err) + } + if len(subs) != 1 || subs[0].AgentAccountID != match { + t.Fatalf("linear opened subs = %+v, want just proj-A agent %q", subs, match) + } +} + +// TestSubscribersForArtifactRejectsZeroNumber: an artifact event MUST name an +// artifact — number=0 is a caller bug. +func TestSubscribersForArtifactRejectsZeroNumber(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + _, err := s.SubscribersForArtifact(ctx, ForgeProviderGitHub, "github.com", "a/b", ForgeArtifactKindIssue, 0, "", false) + sentinelIs(t, err, ErrInvalidArgument, "artifact event with number=0") +} + +// ── T3: ListForgeNotifyTargets — enumeration + grouping ─────────────────────── + +// TestListForgeNotifyTargetsArtifactGrouping: two agents on one artifact collapse +// to ONE target carrying two subscribers, with a nil cursor before any upsert. +func TestListForgeNotifyTargetsArtifactGrouping(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agentA, _ := seedAgent(t, s, "t3-lnt-a") + agentB, _ := seedAgent(t, s, "t3-lnt-b") + + const ( + host = "github.com" + repo = "a/b" + number = uint64(11) + ) + provider := ForgeProviderGitHub + kind := ForgeArtifactKindIssue + + for _, ag := range []AccountID{agentA, agentB} { + if _, err := s.EnsureAgentForgeSubscription(ctx, AgentForgeSubscription{ + AgentAccountID: ag, Provider: provider, Host: host, Repo: repo, Kind: kind, + Number: number, Scope: ForgeSubscriptionScopeArtifact, + }); err != nil { + t.Fatalf("ensure %s: %v", ag, err) + } + } + + targets, err := s.ListForgeNotifyTargets(ctx, provider, host) + if err != nil { + t.Fatalf("ListForgeNotifyTargets: %v", err) + } + if len(targets) != 1 { + t.Fatalf("targets = %d, want 1", len(targets)) + } + tg := targets[0] + if tg.Number != number || tg.Repo != repo || tg.Kind != kind { + t.Fatalf("target coord = %+v, want repo=%q kind=%d number=%d", tg, repo, kind, number) + } + if tg.Cursor != nil { + t.Fatalf("cursor = %+v, want nil before first upsert", tg.Cursor) + } + if len(tg.Subscribers) != 2 { + t.Fatalf("subscribers = %d, want 2", len(tg.Subscribers)) + } + + // After an upsert, the cursor is observed. + if err := s.UpsertForgeArtifactCursor(ctx, ForgeArtifactCursor{ + Provider: provider, Host: host, Repo: repo, Kind: kind, Number: number, + ETag: `"e1"`, Revision: "rev-1", + }); err != nil { + t.Fatalf("UpsertForgeArtifactCursor: %v", err) + } + targets, err = s.ListForgeNotifyTargets(ctx, provider, host) + if err != nil { + t.Fatalf("ListForgeNotifyTargets (post-upsert): %v", err) + } + if len(targets) != 1 || targets[0].Cursor == nil { + t.Fatalf("post-upsert target cursor = %+v, want non-nil", targets) + } + if targets[0].Cursor.Revision != "rev-1" || targets[0].Cursor.ETag != `"e1"` { + t.Fatalf("cursor = %+v, want rev-1 / \"e1\"", targets[0].Cursor) + } +} + +// TestListForgeNotifyTargetsContainerCollapse: N Linear project container subs on +// one team collapse to ONE (repo, kind, number=0) container target carrying all +// N subscribers. +func TestListForgeNotifyTargetsContainerCollapse(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agentA, _ := seedAgent(t, s, "t3-cc-a") + agentB, _ := seedAgent(t, s, "t3-cc-b") + agentC, _ := seedAgent(t, s, "t3-cc-c") + + const ( + host = "linear.app" + repo = "TEAM" + ) + provider := ForgeProviderLinear + kind := ForgeArtifactKindIssue + + projects := map[AccountID]string{agentA: "p1", agentB: "p2", agentC: "p3"} + for ag, proj := range projects { + if _, err := s.EnsureAgentForgeSubscription(ctx, AgentForgeSubscription{ + AgentAccountID: ag, Provider: provider, Host: host, Repo: repo, Kind: kind, + Scope: ForgeSubscriptionScopeContainer, Project: proj, + }); err != nil { + t.Fatalf("ensure %s: %v", ag, err) + } + } + + targets, err := s.ListForgeNotifyTargets(ctx, provider, host) + if err != nil { + t.Fatalf("ListForgeNotifyTargets: %v", err) + } + if len(targets) != 1 { + t.Fatalf("targets = %d, want 1 collapsed container target", len(targets)) + } + tg := targets[0] + if tg.Number != 0 || tg.Repo != repo || tg.Kind != kind { + t.Fatalf("container target coord = %+v, want repo=%q kind=%d number=0", tg, repo, kind) + } + if len(tg.Subscribers) != 3 { + t.Fatalf("container subscribers = %d, want 3", len(tg.Subscribers)) + } + // Each collapsed subscriber must carry back its own project — the router + // fans a project-P change out to only its project-P subscribers, so the + // per-subscriber project must survive the (repo, kind) collapse. + gotProjects := make(map[AccountID]string, len(tg.Subscribers)) + for _, sub := range tg.Subscribers { + gotProjects[sub.AgentAccountID] = sub.Project + } + for ag, want := range projects { + if got := gotProjects[ag]; got != want { + t.Fatalf("subscriber %s project = %q, want %q", ag, got, want) + } + } + + // The container arm of the cursor LEFT JOIN (c.number = 0 for scope=2) is + // exercised by upserting a cursor at the CONTAINER coordinate (Number:0) and + // asserting the collapsed target picks it up — mirrors the number=11 + // post-upsert assertion in TestListForgeNotifyTargetsArtifactGrouping. + if tg.Cursor != nil { + t.Fatalf("container cursor = %+v, want nil before first upsert", tg.Cursor) + } + if err := s.UpsertForgeArtifactCursor(ctx, ForgeArtifactCursor{ + Provider: provider, Host: host, Repo: repo, Kind: kind, Number: 0, + ETag: `"c1"`, Revision: "rev-c1", + }); err != nil { + t.Fatalf("UpsertForgeArtifactCursor (container): %v", err) + } + targets, err = s.ListForgeNotifyTargets(ctx, provider, host) + if err != nil { + t.Fatalf("ListForgeNotifyTargets (post-upsert): %v", err) + } + if len(targets) != 1 || targets[0].Cursor == nil { + t.Fatalf("post-upsert container target cursor = %+v, want non-nil", targets) + } + if targets[0].Cursor.Revision != "rev-c1" || targets[0].Cursor.ETag != `"c1"` { + t.Fatalf("container cursor = %+v, want rev-c1 / \"c1\"", targets[0].Cursor) + } +} + +// TestListForgeNotifyTargetsMixedArtifactAndContainer: one artifact sub +// (number>0) and two container subs (number=0, distinct projects) on the same +// (repo, kind) yield exactly TWO targets — the artifact target stands alone +// (number>0) while the two container subs collapse to ONE (number=0) target. +// This pins the collapse-vs-distinct boundary of the +// CASE WHEN s.scope=2 THEN 0 ELSE s.number END grouping. +func TestListForgeNotifyTargetsMixedArtifactAndContainer(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + artAgent, _ := seedAgent(t, s, "t3-mix-art") + c1Agent, _ := seedAgent(t, s, "t3-mix-c1") + c2Agent, _ := seedAgent(t, s, "t3-mix-c2") + + const ( + host = "linear.app" + repo = "TEAM" + number = uint64(7) + ) + provider := ForgeProviderLinear + kind := ForgeArtifactKindIssue + + if _, err := s.EnsureAgentForgeSubscription(ctx, AgentForgeSubscription{ + AgentAccountID: artAgent, Provider: provider, Host: host, Repo: repo, Kind: kind, + Number: number, Scope: ForgeSubscriptionScopeArtifact, + }); err != nil { + t.Fatalf("ensure artifact: %v", err) + } + for ag, proj := range map[AccountID]string{c1Agent: "p1", c2Agent: "p2"} { + if _, err := s.EnsureAgentForgeSubscription(ctx, AgentForgeSubscription{ + AgentAccountID: ag, Provider: provider, Host: host, Repo: repo, Kind: kind, + Scope: ForgeSubscriptionScopeContainer, Project: proj, + }); err != nil { + t.Fatalf("ensure container %s: %v", ag, err) + } + } + + targets, err := s.ListForgeNotifyTargets(ctx, provider, host) + if err != nil { + t.Fatalf("ListForgeNotifyTargets: %v", err) + } + if len(targets) != 2 { + t.Fatalf("targets = %d, want 2 (one artifact + one collapsed container)", len(targets)) + } + var artTarget, containerTarget *ForgeNotifyTarget + for i := range targets { + switch targets[i].Number { + case number: + artTarget = &targets[i] + case 0: + containerTarget = &targets[i] + default: + t.Fatalf("unexpected target number %d", targets[i].Number) + } + } + if artTarget == nil { + t.Fatalf("no artifact target (number=%d) in %+v", number, targets) + } + if len(artTarget.Subscribers) != 1 { + t.Fatalf("artifact subscribers = %d, want 1", len(artTarget.Subscribers)) + } + if containerTarget == nil { + t.Fatalf("no collapsed container target (number=0) in %+v", targets) + } + if len(containerTarget.Subscribers) != 2 { + t.Fatalf("container subscribers = %d, want 2", len(containerTarget.Subscribers)) + } +} + +// ── T3: AdvanceForgeDeliveredRevision ───────────────────────────────────────── + +// TestAdvanceForgeDeliveredRevision: happy path advances the cursor; an unknown +// id and a foreign-agent id both return ErrNotFound. +func TestAdvanceForgeDeliveredRevision(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + owner, _ := seedAgent(t, s, "t3-adv-owner") + foreign, _ := seedAgent(t, s, "t3-adv-foreign") + + id, err := s.EnsureAgentForgeSubscription(ctx, AgentForgeSubscription{ + AgentAccountID: owner, Provider: ForgeProviderGitHub, Host: "github.com", + Repo: "a/b", Kind: ForgeArtifactKindIssue, Number: 1, Scope: ForgeSubscriptionScopeArtifact, + }) + if err != nil { + t.Fatalf("ensure: %v", err) + } + + // Happy path. + if err := s.AdvanceForgeDeliveredRevision(ctx, owner, id, "rev-9"); err != nil { + t.Fatalf("advance happy: %v", err) + } + var got string + if err := s.pool.QueryRow(ctx, + `SELECT delivered_revision FROM agent_forge_subscriptions WHERE id = $1`, id, + ).Scan(&got); err != nil { + t.Fatalf("read delivered_revision: %v", err) + } + if got != "rev-9" { + t.Fatalf("delivered_revision = %q, want rev-9", got) + } + + // Unknown id -> ErrNotFound. + sentinelIs(t, s.AdvanceForgeDeliveredRevision(ctx, owner, "no-such-id", "rev-x"), ErrNotFound, "advance unknown id") + // Foreign agent on a real id -> ErrNotFound (scoping), row untouched. + sentinelIs(t, s.AdvanceForgeDeliveredRevision(ctx, foreign, id, "rev-x"), ErrNotFound, "advance foreign agent") + // Empty agent / empty subscription id -> ErrInvalidArgument (early guards). + sentinelIs(t, s.AdvanceForgeDeliveredRevision(ctx, "", id, "rev-x"), ErrInvalidArgument, "advance empty agent") + sentinelIs(t, s.AdvanceForgeDeliveredRevision(ctx, owner, "", "rev-x"), ErrInvalidArgument, "advance empty id") + if err := s.pool.QueryRow(ctx, + `SELECT delivered_revision FROM agent_forge_subscriptions WHERE id = $1`, id, + ).Scan(&got); err != nil { + t.Fatalf("re-read delivered_revision: %v", err) + } + if got != "rev-9" { + t.Fatalf("delivered_revision after foreign advance = %q, want rev-9 (untouched)", got) + } +} diff --git a/go/internal/store/migrations/0001_init.sql b/go/internal/store/migrations/0001_init.sql index 990ca149f..30619ea6c 100644 --- a/go/internal/store/migrations/0001_init.sql +++ b/go/internal/store/migrations/0001_init.sql @@ -624,8 +624,12 @@ CREATE TABLE forge_repo_subscriptions ( ); -- DL-053's forge_subscriptions, renamed agent_forge_subscriptions (OQ-C) and --- coordinate-aligned. The UNIQUE (agent, coordinate, kind, number) makes an --- agent's subscription to one artifact idempotent. +-- coordinate-aligned. The UNIQUE (agent, coordinate, kind, number, project) +-- makes an agent's subscription to one artifact (or one container) idempotent. +-- scope (RIG-2732 T3, OQ-1 ruled (i)) discriminates ARTIFACT(1) rows (number>0, +-- project='') from CONTAINER(2) rows (number=0; project=the Linear project id on +-- LINEAR, '' on GitHub). project rides the UNIQUE so two Linear project +-- containers on one team do not collide. CREATE TABLE agent_forge_subscriptions ( id TEXT PRIMARY KEY, agent_account_id TEXT NOT NULL REFERENCES agent_accounts (account_id) ON DELETE RESTRICT, @@ -634,10 +638,12 @@ CREATE TABLE agent_forge_subscriptions ( repo TEXT NOT NULL, kind SMALLINT NOT NULL CHECK (kind IN (1, 2)), number BIGINT NOT NULL, + scope SMALLINT NOT NULL DEFAULT 1 CHECK (scope IN (1, 2)), -- 1 artifact, 2 container + project TEXT NOT NULL DEFAULT '', -- Linear CONTAINER rows: project id; else '' delivered_revision TEXT NOT NULL DEFAULT '', delivered_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (agent_account_id, forge_provider, forge_host, repo, kind, number) + UNIQUE (agent_account_id, forge_provider, forge_host, repo, kind, number, project) ); CREATE INDEX agent_forge_subscriptions_artifact_idx diff --git a/go/server/github_webhook.go b/go/server/github_webhook.go new file mode 100644 index 000000000..a2c483c2d --- /dev/null +++ b/go/server/github_webhook.go @@ -0,0 +1,193 @@ +//go:build unix + +// The GitHub App webhook ingress: a plain http.Handler for +// POST /webhooks/github, the DL-254 shape (verify signature -> ack 200 fast -> +// enqueue async), with an in-memory delivery-id LRU for dedup and an +// oversized-body guard. T7 mounts it and supplies the sink; this file only +// builds the handler. +package server + +import ( + "container/list" + "context" + "errors" + "io" + "log/slog" + "net/http" + "sync" + + "github.com/RigelBuild/compass/go/internal/forge" +) + +const ( + // githubWebhookPath is the ingress path T7 mounts this handler at. + githubWebhookPath = "/webhooks/github" + + // githubWebhookMaxBody bounds a webhook request body (bytes). The 1 MiB + // ceiling keeps the per-request buffered-body allocation small — these + // events are single-digit KB — and rejects anything larger before + // buffering it, closing the memory-amplification window the raw-body HMAC + // read would otherwise open. + githubWebhookMaxBody = 1 << 20 + + // githubDeliveryLRUSize is the number of recent X-GitHub-Delivery ids the + // dedup LRU retains. GitHub redelivers on our slow ack or its own retry; + // the LRU drops a repeat within this window so the sink sees each delivery + // once. + githubDeliveryLRUSize = 4096 + + githubEventHeader = "X-Github-Event" + githubDeliveryHeader = "X-Github-Delivery" + githubSignatureHeader = "X-Hub-Signature-256" +) + +// ForgeEventSink receives a normalized event the webhook ingress accepted. T4's +// router satisfies it; the handler calls Enqueue after acking 200, so a slow +// downstream never delays the ack GitHub's delivery timeout depends on. +type ForgeEventSink interface { + // Enqueue MUST NOT block: it hands the event off to the async drain loop + // and returns immediately, so the ack is never on its latency path. + Enqueue(ctx context.Context, ev forge.ForgeEvent) +} + +// deliveryLRU is a fixed-capacity set of delivery ids with LRU eviction. seen +// reports whether id was already present and records it either way; it is the +// dedup gate. Safe for concurrent use. +type deliveryLRU struct { + mu sync.Mutex + cap int + order *list.List + index map[string]*list.Element +} + +func newDeliveryLRU(capacity int) *deliveryLRU { + return &deliveryLRU{ + cap: capacity, + order: list.New(), + index: make(map[string]*list.Element, capacity), + } +} + +// seen records id and reports whether it was already present. An empty id is +// never deduped (returns false without recording) — an absent delivery header +// must not collapse distinct deliveries onto one empty key. +func (l *deliveryLRU) seen(id string) bool { + if id == "" { + return false + } + l.mu.Lock() + defer l.mu.Unlock() + if el, ok := l.index[id]; ok { + l.order.MoveToFront(el) + return true + } + l.index[id] = l.order.PushFront(id) + if l.order.Len() > l.cap { + oldest := l.order.Back() + if oldest != nil { + l.order.Remove(oldest) + if key, ok := oldest.Value.(string); ok { + delete(l.index, key) + } + } + } + return false +} + +// githubWebhookHandler serves POST /webhooks/github. +type githubWebhookHandler struct { + secret func(ctx context.Context) ([]byte, error) + sink ForgeEventSink + lru *deliveryLRU + maxBody int64 + log *slog.Logger +} + +// NewGitHubWebhookHandler returns the POST /webhooks/github handler and the +// path it mounts at. secret lazily resolves the App webhook secret (a +// server_only secret); sink receives every accepted event. T7 wires both and +// mounts the returned handler. +func NewGitHubWebhookHandler( + secret func(ctx context.Context) ([]byte, error), + sink ForgeEventSink, + log *slog.Logger, +) (string, http.Handler) { + if log == nil { + log = slog.Default() + } + return githubWebhookPath, &githubWebhookHandler{ + secret: secret, + sink: sink, + lru: newDeliveryLRU(githubDeliveryLRUSize), + maxBody: githubWebhookMaxBody, + log: log, + } +} + +func (h *githubWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ctx := r.Context() + + // Bound the body read before buffering it (oversized-body rejection). A + // body over the cap trips MaxBytesReader mid-read, so a hostile + // Content-Length never forces the full allocation. + r.Body = http.MaxBytesReader(w, r.Body, h.maxBody) + + body, err := io.ReadAll(r.Body) + if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + http.Error(w, "payload too large", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + secret, err := h.secret(ctx) + if err != nil { + h.log.ErrorContext(ctx, "github webhook secret unavailable", "err", err) + http.Error(w, "webhook unavailable", http.StatusServiceUnavailable) + return + } + if !forge.VerifyGitHubSignature(secret, body, r.Header.Get(githubSignatureHeader)) { + // Fail-closed: an unverifiable delivery is a 400, never processed. + http.Error(w, "invalid signature", http.StatusBadRequest) + return + } + + delivery := r.Header.Get(githubDeliveryHeader) + if h.lru.seen(delivery) { + // A redelivery of an already-accepted event: ack and drop. + w.WriteHeader(http.StatusOK) + return + } + + event := r.Header.Get(githubEventHeader) + ev, ok, perr := forge.ParseGitHubEvent(event, body) + if perr != nil { + h.log.WarnContext(ctx, "github webhook parse error", "event", event, "delivery", delivery, "err", perr) + // A malformed payload we verified is still ack'd (retrying it will not + // help); it is counted-and-dropped. + w.WriteHeader(http.StatusOK) + return + } + + // Ack fast, enqueue after: GitHub's delivery timeout depends on a prompt + // 200, and the sink must never be on that latency path (DL-254 shape). + w.WriteHeader(http.StatusOK) + if !ok { + return // ignored event/action: counted-and-dropped. + } + // Flush the ack onto the wire before handing off: WriteHeader only records + // the status, so a blocking Enqueue would otherwise delay the client-visible + // 200 until ServeHTTP returns. + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + ev.DeliveryID = delivery + h.sink.Enqueue(ctx, ev) +} diff --git a/go/server/github_webhook_test.go b/go/server/github_webhook_test.go new file mode 100644 index 000000000..9b2656457 --- /dev/null +++ b/go/server/github_webhook_test.go @@ -0,0 +1,210 @@ +//go:build unix + +// Unit tests for the GitHub App webhook ingress handler: signature fail-closed, +// oversized-body rejection, delivery-id dedup, and the ack-then-enqueue shape +// (design.md:667-674 — "oversized body rejected by the mount"). +package server + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/RigelBuild/compass/go/internal/forge" +) + +type recordingSink struct { + mu sync.Mutex + events []forge.ForgeEvent +} + +func (s *recordingSink) Enqueue(_ context.Context, ev forge.ForgeEvent) { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, ev) +} + +func (s *recordingSink) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.events) +} + +func ghSign(secret, body []byte) string { + mac := hmac.New(sha256.New, secret) + mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +func newTestHandler(t *testing.T, secret []byte) (http.Handler, *recordingSink) { + t.Helper() + sink := &recordingSink{} + _, h := NewGitHubWebhookHandler( + func(context.Context) ([]byte, error) { return secret, nil }, + sink, nil, + ) + return h, sink +} + +func doPost(h http.Handler, event, delivery, sig string, body []byte) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, githubWebhookPath, strings.NewReader(string(body))) + if event != "" { + req.Header.Set(githubEventHeader, event) + } + if delivery != "" { + req.Header.Set(githubDeliveryHeader, delivery) + } + if sig != "" { + req.Header.Set(githubSignatureHeader, sig) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestGitHubWebhookHandler_ValidEnqueues(t *testing.T) { + secret := []byte("shh") + h, sink := newTestHandler(t, secret) + body := []byte(`{"action":"opened","issue":{"number":1,"html_url":"u"},"repository":{"full_name":"o/r"}}`) + + rec := doPost(h, "issues", "d1", ghSign(secret, body), body) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, want 200", rec.Code) + } + if sink.count() != 1 { + t.Fatalf("enqueued = %d, want 1", sink.count()) + } + if got := sink.events[0].DeliveryID; got != "d1" { + t.Errorf("DeliveryID = %q, want d1", got) + } +} + +func TestGitHubWebhookHandler_BadSignature(t *testing.T) { + secret := []byte("shh") + h, sink := newTestHandler(t, secret) + body := []byte(`{"action":"opened","issue":{"number":1},"repository":{"full_name":"o/r"}}`) + + rec := doPost(h, "issues", "d1", "sha256=deadbeef", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec.Code) + } + if sink.count() != 0 { + t.Errorf("enqueued = %d, want 0 (fail-closed)", sink.count()) + } +} + +func TestGitHubWebhookHandler_OversizedBody(t *testing.T) { + secret := []byte("shh") + sink := &recordingSink{} + _, base := NewGitHubWebhookHandler( + func(context.Context) ([]byte, error) { return secret, nil }, sink, nil) + // Shrink the body cap on the concrete handler so the over-cap path fires + // without materializing a 25 MiB body; the cap enforcement is identical. + h := base.(*githubWebhookHandler) + h.maxBody = 16 + big := []byte(`{"action":"opened","issue":{"number":1},"repository":{"full_name":"o/r"}}`) + rec := doPost(h, "issues", "d1", ghSign(secret, big), big) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("code = %d, want 413", rec.Code) + } + if sink.count() != 0 { + t.Errorf("enqueued = %d, want 0", sink.count()) + } +} + +func TestGitHubWebhookHandler_Dedup(t *testing.T) { + secret := []byte("shh") + h, sink := newTestHandler(t, secret) + body := []byte(`{"action":"opened","issue":{"number":1,"html_url":"u"},"repository":{"full_name":"o/r"}}`) + sig := ghSign(secret, body) + + first := doPost(h, "issues", "dup", sig, body) + second := doPost(h, "issues", "dup", sig, body) + if first.Code != http.StatusOK || second.Code != http.StatusOK { + t.Fatalf("codes = %d,%d, want 200,200", first.Code, second.Code) + } + if sink.count() != 1 { + t.Errorf("enqueued = %d, want 1 (second is a dedup drop)", sink.count()) + } +} + +func TestGitHubWebhookHandler_IgnoredEventAcks(t *testing.T) { + secret := []byte("shh") + h, sink := newTestHandler(t, secret) + body := []byte(`{"ref":"refs/heads/main"}`) + + rec := doPost(h, "push", "d1", ghSign(secret, body), body) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, want 200", rec.Code) + } + if sink.count() != 0 { + t.Errorf("enqueued = %d, want 0 (ignored event)", sink.count()) + } +} + +func TestGitHubWebhookHandler_VerifiedMalformedAcksAndDrops(t *testing.T) { + secret := []byte("shh") + h, sink := newTestHandler(t, secret) + // A mapped event (issues) whose body is invalid JSON: verified but + // unparseable, so it is ack'd and dropped (never enqueued). + body := []byte(`{"action":"opened","issue":`) + + rec := doPost(h, "issues", "d1", ghSign(secret, body), body) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, want 200", rec.Code) + } + if sink.count() != 0 { + t.Errorf("enqueued = %d, want 0 (verified-but-malformed drop)", sink.count()) + } +} + +func TestGitHubWebhookHandler_MissingSignature(t *testing.T) { + secret := []byte("shh") + h, sink := newTestHandler(t, secret) + body := []byte(`{"action":"opened","issue":{"number":1,"html_url":"u"},"repository":{"full_name":"o/r"}}`) + + rec := doPost(h, "issues", "d1", "", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec.Code) + } + if sink.count() != 0 { + t.Errorf("enqueued = %d, want 0 (fail-closed)", sink.count()) + } +} + +func TestGitHubWebhookHandler_SecretError(t *testing.T) { + secret := []byte("shh") + sink := &recordingSink{} + _, h := NewGitHubWebhookHandler( + func(context.Context) ([]byte, error) { + return nil, errors.New("secret unavailable") + }, + sink, nil, + ) + body := []byte(`{"action":"opened","issue":{"number":1,"html_url":"u"},"repository":{"full_name":"o/r"}}`) + + rec := doPost(h, "issues", "d1", ghSign(secret, body), body) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("code = %d, want 503", rec.Code) + } + if sink.count() != 0 { + t.Errorf("enqueued = %d, want 0", sink.count()) + } +} + +func TestGitHubWebhookHandler_RejectsGET(t *testing.T) { + h, _ := newTestHandler(t, []byte("shh")) + req := httptest.NewRequest(http.MethodGet, githubWebhookPath, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("code = %d, want 405", rec.Code) + } +} diff --git a/packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts b/packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts index c618c2b5b..e49b024f1 100644 --- a/packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts +++ b/packages/compass-agent/src/gen/compass/v1/agent_gateway_pb.ts @@ -32,8 +32,8 @@ // @generated from file compass/v1/agent_gateway.proto (package compass.v1, syntax proto3) /* eslint-disable */ -import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; -import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; import type { GetRosterRequest, GetRosterResponse, ListMessagesRequest, ListMessagesResponse, PostMessageRequest, PostMessageResponse, UpdatePinnedBoardRequest, UpdatePinnedBoardResponse } from "./comms_pb"; import { file_compass_v1_comms } from "./comms_pb"; import type { AgentControlSchema, AgentFrame } from "./agent_pb"; @@ -48,7 +48,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file compass/v1/agent_gateway.proto. */ export const file_compass_v1_agent_gateway: GenFile = /*@__PURE__*/ - fileDesc("Ch5jb21wYXNzL3YxL2FnZW50X2dhdGV3YXkucHJvdG8SCmNvbXBhc3MudjEiqgIKEENvbW1zQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRIuCgRwb3N0GAIgASgLMh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3RIABIvCgRsaXN0GAMgASgLMh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0SAASLgoGcm9zdGVyGAQgASgLMhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0SAASNwoKc2V0X3N0YXR1cxgFIAEoCzIhLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXF1ZXN0SAASMwoDcGluGAYgASgLMiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3RIAEIGCgRjYWxsIt0CCg9Db21tc0NhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIvCgRwb3N0GAIgASgLMh8uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlc3BvbnNlSAASMAoEbGlzdBgDIAEoCzIgLmNvbXBhc3MudjEuTGlzdE1lc3NhZ2VzUmVzcG9uc2VIABIrCgVlcnJvchgEIAEoCzIaLmNvbXBhc3MudjEuQ29tbXNDYWxsRXJyb3JIABIvCgZyb3N0ZXIYBSABKAsyHS5jb21wYXNzLnYxLkdldFJvc3RlclJlc3BvbnNlSAASOAoKc2V0X3N0YXR1cxgGIAEoCzIiLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXNwb25zZUgAEjQKA3BpbhgHIAEoCzIlLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXNwb25zZUgAQggKBnJlc3VsdCIvCg5Db21tc0NhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiKQoVU2V0QWdlbnRTdGF0dXNSZXF1ZXN0EhAKCGFjdGl2aXR5GAEgASgJIhgKFlNldEFnZW50U3RhdHVzUmVzcG9uc2UikQEKFExpZmVjeWNsZUNhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSLQoFc3Bhd24YAiABKAsyHC5jb21wYXNzLnYxLlNwYXduUGVlclJlcXVlc3RIABIxCgdkZXNwYXduGAMgASgLMh4uY29tcGFzcy52MS5EZXNwYXduUGVlclJlcXVlc3RIAEIGCgRjYWxsImkKEFNwYXduUGVlclJlcXVlc3QSDgoGaGFuZGxlGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRIZChFjbGllbnRfcmVxdWVzdF9pZBgEIAEoCUoECAMQBFIOaW5pdGlhbF9wcm9tcHQiWQoRU3Bhd25QZWVyUmVzcG9uc2USGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIWCg5jb250YWluZXJfbmFtZRgCIAEoCRISCgpzZXNzaW9uX2lkGAMgASgJIi4KEkRlc3Bhd25QZWVyUmVxdWVzdBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJIhUKE0Rlc3Bhd25QZWVyUmVzcG9uc2UixQEKE0xpZmVjeWNsZUNhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIuCgVzcGF3bhgCIAEoCzIdLmNvbXBhc3MudjEuU3Bhd25QZWVyUmVzcG9uc2VIABIyCgdkZXNwYXduGAMgASgLMh8uY29tcGFzcy52MS5EZXNwYXduUGVlclJlc3BvbnNlSAASLwoFZXJyb3IYBCABKAsyHi5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxFcnJvckgAQggKBnJlc3VsdCIzChJMaWZlY3ljbGVDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIsgFChBGb3JnZUNhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSNgoMY3JlYXRlX2lzc3VlGAIgASgLMh4uY29tcGFzcy52MS5DcmVhdGVJc3N1ZVJlcXVlc3RIABI9ChBjb21tZW50X29uX2lzc3VlGAMgASgLMiEuY29tcGFzcy52MS5Db21tZW50T25Jc3N1ZVJlcXVlc3RIABIwCglnZXRfaXNzdWUYBCABKAsyGy5jb21wYXNzLnYxLkdldElzc3VlUmVxdWVzdEgAEjQKC2xpc3RfaXNzdWVzGAUgASgLMh0uY29tcGFzcy52MS5MaXN0SXNzdWVzUmVxdWVzdEgAEkMKE2NyZWF0ZV9wdWxsX3JlcXVlc3QYBiABKAsyJC5jb21wYXNzLnYxLkNyZWF0ZVB1bGxSZXF1ZXN0UmVxdWVzdEgAEkoKF2NvbW1lbnRfb25fcHVsbF9yZXF1ZXN0GAcgASgLMicuY29tcGFzcy52MS5Db21tZW50T25QdWxsUmVxdWVzdFJlcXVlc3RIABI9ChBnZXRfcHVsbF9yZXF1ZXN0GAggASgLMiEuY29tcGFzcy52MS5HZXRQdWxsUmVxdWVzdFJlcXVlc3RIABI2CglzdWJzY3JpYmUYCSABKAsyIS5jb21wYXNzLnYxLlN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjoKC3Vuc3Vic2NyaWJlGAogASgLMiMuY29tcGFzcy52MS5VbnN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjgKDXN1Ym1pdF9yZXZpZXcYCyABKAsyHy5jb21wYXNzLnYxLlN1Ym1pdFJldmlld1JlcXVlc3RIABIjCgVmb3JnZRgMIAEoCzIULmNvbXBhc3MudjEuRm9yZ2VSZWYSGQoRY2xpZW50X3JlcXVlc3RfaWQYDSABKAlCBgoEY2FsbCLgAwoPRm9yZ2VDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSIgoFaXNzdWUYAiABKAsyES5jb21wYXNzLnYxLklzc3VlSAASLwoNaXNzdWVfY29tbWVudBgDIAEoCzIWLmNvbXBhc3MudjEuQ29tbWVudFJlZkgAEjAKBmlzc3VlcxgEIAEoCzIeLmNvbXBhc3MudjEuTGlzdElzc3Vlc1Jlc3BvbnNlSAASLwoMcHVsbF9yZXF1ZXN0GAUgASgLMhcuY29tcGFzcy52MS5QdWxsUmVxdWVzdEgAEiwKCnByX2NvbW1lbnQYBiABKAsyFi5jb21wYXNzLnYxLkNvbW1lbnRSZWZIABI4CgpzdWJzY3JpYmVkGAcgASgLMiIuY29tcGFzcy52MS5TdWJzY3JpYmVGb3JnZVJlc3BvbnNlSAASPAoMdW5zdWJzY3JpYmVkGAggASgLMiQuY29tcGFzcy52MS5VbnN1YnNjcmliZUZvcmdlUmVzcG9uc2VIABIrCgVlcnJvchgJIAEoCzIaLmNvbXBhc3MudjEuRm9yZ2VDYWxsRXJyb3JIABInCgZyZXZpZXcYCiABKAsyFS5jb21wYXNzLnYxLlJldmlld1JlZkgAQggKBnJlc3VsdCJHCg5Gb3JnZUNhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkSFgoOcmV0cnlfYWZ0ZXJfbXMYAyABKA0iTwoSQ3JlYXRlSXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFdGl0bGUYAiABKAkSDAoEYm9keRgDIAEoCRIOCgZsYWJlbHMYBCADKAkiSQoVQ29tbWVudE9uSXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSFAoMaXNzdWVfbnVtYmVyGAIgASgEEgwKBGJvZHkYAyABKAkiNQoPR2V0SXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSFAoMaXNzdWVfbnVtYmVyGAIgASgEIk8KEUxpc3RJc3N1ZXNSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFc3RhdGUYAiABKAkSDgoGbGFiZWxzGAMgAygJEg0KBWxpbWl0GAQgASgNIjcKEkxpc3RJc3N1ZXNSZXNwb25zZRIhCgZpc3N1ZXMYASADKAsyES5jb21wYXNzLnYxLklzc3VlIngKGENyZWF0ZVB1bGxSZXF1ZXN0UmVxdWVzdBIMCgRyZXBvGAEgASgJEg0KBXRpdGxlGAIgASgJEgwKBGJvZHkYAyABKAkSEAoIaGVhZF9yZWYYBCABKAkSEAoIYmFzZV9yZWYYBSABKAkSDQoFZHJhZnQYBiABKAgiTgobQ29tbWVudE9uUHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSEwoLcHVsbF9udW1iZXIYAiABKAQSDAoEYm9keRgDIAEoCSI6ChVHZXRQdWxsUmVxdWVzdFJlcXVlc3QSDAoEcmVwbxgBIAEoCRITCgtwdWxsX251bWJlchgCIAEoBCKJAQoTU3VibWl0UmV2aWV3UmVxdWVzdBIMCgRyZXBvGAEgASgJEhMKC3B1bGxfbnVtYmVyGAIgASgEEg8KB3ZlcmRpY3QYAyABKAkSDAoEYm9keRgEIAEoCRIwCghjb21tZW50cxgFIAMoCzIeLmNvbXBhc3MudjEuUmV2aWV3Q29tbWVudElucHV0IkwKElJldmlld0NvbW1lbnRJbnB1dBIMCgRwYXRoGAEgASgJEgwKBGxpbmUYAiABKA0SDAoEc2lkZRgDIAEoCRIMCgRib2R5GAQgASgJImIKFVN1YnNjcmliZUZvcmdlUmVxdWVzdBIMCgRyZXBvGAEgASgJEisKBGtpbmQYAiABKA4yHS5jb21wYXNzLnYxLkZvcmdlQXJ0aWZhY3RLaW5kEg4KBm51bWJlchgDIAEoBCIxChZTdWJzY3JpYmVGb3JnZVJlc3BvbnNlEhcKD3N1YnNjcmlwdGlvbl9pZBgBIAEoCSIyChdVbnN1YnNjcmliZUZvcmdlUmVxdWVzdBIXCg9zdWJzY3JpcHRpb25faWQYASABKAkiGgoYVW5zdWJzY3JpYmVGb3JnZVJlc3BvbnNlImgKEEJvYXJkQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRI7Cg9zZXRfaXNzdWVfc3RhdGUYAiABKAsyIC5jb21wYXNzLnYxLlNldElzc3VlU3RhdGVSZXF1ZXN0SABCBgoEY2FsbCJPChRTZXRJc3N1ZVN0YXRlUmVxdWVzdBIQCghpc3N1ZV9pZBgBIAEoCRIlCgVzdGF0ZRgCIAEoDjIWLmNvbXBhc3MudjEuSXNzdWVTdGF0ZSI5ChVTZXRJc3N1ZVN0YXRlUmVzcG9uc2USIAoFaXNzdWUYASABKAsyES5jb21wYXNzLnYxLklzc3VlIpcBCg9Cb2FyZENhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRI8Cg9zZXRfaXNzdWVfc3RhdGUYAiABKAsyIS5jb21wYXNzLnYxLlNldElzc3VlU3RhdGVSZXNwb25zZUgAEisKBWVycm9yGAMgASgLMhouY29tcGFzcy52MS5Cb2FyZENhbGxFcnJvckgAQggKBnJlc3VsdCIvCg5Cb2FyZENhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiPAoTUHVibGlzaEZyYW1lUmVxdWVzdBIlCgVmcmFtZRgBIAEoCzIWLmNvbXBhc3MudjEuQWdlbnRGcmFtZSIWChRQdWJsaXNoRnJhbWVSZXNwb25zZSJeChxQb3N0Q29udmVyc2F0aW9uRnJhbWVSZXF1ZXN0EiUKBWZyYW1lGAEgASgLMhYuY29tcGFzcy52MS5BZ2VudEZyYW1lEhcKD2lkZW1wb3RlbmN5X2tleRgCIAEoCSIfCh1Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXNwb25zZSIZChdDb250cm9sU3Vic2NyaWJlUmVxdWVzdDK0BAoMQWdlbnRHYXRld2F5EkIKBUNvbW1zEhwuY29tcGFzcy52MS5Db21tc0NhbGxSZXF1ZXN0GhsuY29tcGFzcy52MS5Db21tc0NhbGxSZXN1bHQSTgoJTGlmZWN5Y2xlEiAuY29tcGFzcy52MS5MaWZlY3ljbGVDYWxsUmVxdWVzdBofLmNvbXBhc3MudjEuTGlmZWN5Y2xlQ2FsbFJlc3VsdBJOCgdQdWJsaXNoEh8uY29tcGFzcy52MS5QdWJsaXNoRnJhbWVSZXF1ZXN0GiAuY29tcGFzcy52MS5QdWJsaXNoRnJhbWVSZXNwb25zZSgBEmwKFVBvc3RDb252ZXJzYXRpb25GcmFtZRIoLmNvbXBhc3MudjEuUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVxdWVzdBopLmNvbXBhc3MudjEuUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVzcG9uc2USSgoHQ29udHJvbBIjLmNvbXBhc3MudjEuQ29udHJvbFN1YnNjcmliZVJlcXVlc3QaGC5jb21wYXNzLnYxLkFnZW50Q29udHJvbDABEkIKBUZvcmdlEhwuY29tcGFzcy52MS5Gb3JnZUNhbGxSZXF1ZXN0GhsuY29tcGFzcy52MS5Gb3JnZUNhbGxSZXN1bHQSQgoFQm9hcmQSHC5jb21wYXNzLnYxLkJvYXJkQ2FsbFJlcXVlc3QaGy5jb21wYXNzLnYxLkJvYXJkQ2FsbFJlc3VsdGIGcHJvdG8z", [file_compass_v1_comms, file_compass_v1_agent, file_compass_v1_compass, file_compass_v1_forge]); + fileDesc("Ch5jb21wYXNzL3YxL2FnZW50X2dhdGV3YXkucHJvdG8SCmNvbXBhc3MudjEiqgIKEENvbW1zQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRIuCgRwb3N0GAIgASgLMh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3RIABIvCgRsaXN0GAMgASgLMh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0SAASLgoGcm9zdGVyGAQgASgLMhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0SAASNwoKc2V0X3N0YXR1cxgFIAEoCzIhLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXF1ZXN0SAASMwoDcGluGAYgASgLMiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3RIAEIGCgRjYWxsIt0CCg9Db21tc0NhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIvCgRwb3N0GAIgASgLMh8uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlc3BvbnNlSAASMAoEbGlzdBgDIAEoCzIgLmNvbXBhc3MudjEuTGlzdE1lc3NhZ2VzUmVzcG9uc2VIABIrCgVlcnJvchgEIAEoCzIaLmNvbXBhc3MudjEuQ29tbXNDYWxsRXJyb3JIABIvCgZyb3N0ZXIYBSABKAsyHS5jb21wYXNzLnYxLkdldFJvc3RlclJlc3BvbnNlSAASOAoKc2V0X3N0YXR1cxgGIAEoCzIiLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXNwb25zZUgAEjQKA3BpbhgHIAEoCzIlLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXNwb25zZUgAQggKBnJlc3VsdCIvCg5Db21tc0NhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiKQoVU2V0QWdlbnRTdGF0dXNSZXF1ZXN0EhAKCGFjdGl2aXR5GAEgASgJIhgKFlNldEFnZW50U3RhdHVzUmVzcG9uc2UikQEKFExpZmVjeWNsZUNhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSLQoFc3Bhd24YAiABKAsyHC5jb21wYXNzLnYxLlNwYXduUGVlclJlcXVlc3RIABIxCgdkZXNwYXduGAMgASgLMh4uY29tcGFzcy52MS5EZXNwYXduUGVlclJlcXVlc3RIAEIGCgRjYWxsImkKEFNwYXduUGVlclJlcXVlc3QSDgoGaGFuZGxlGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRIZChFjbGllbnRfcmVxdWVzdF9pZBgEIAEoCUoECAMQBFIOaW5pdGlhbF9wcm9tcHQiWQoRU3Bhd25QZWVyUmVzcG9uc2USGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIWCg5jb250YWluZXJfbmFtZRgCIAEoCRISCgpzZXNzaW9uX2lkGAMgASgJIi4KEkRlc3Bhd25QZWVyUmVxdWVzdBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJIhUKE0Rlc3Bhd25QZWVyUmVzcG9uc2UixQEKE0xpZmVjeWNsZUNhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIuCgVzcGF3bhgCIAEoCzIdLmNvbXBhc3MudjEuU3Bhd25QZWVyUmVzcG9uc2VIABIyCgdkZXNwYXduGAMgASgLMh8uY29tcGFzcy52MS5EZXNwYXduUGVlclJlc3BvbnNlSAASLwoFZXJyb3IYBCABKAsyHi5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxFcnJvckgAQggKBnJlc3VsdCIzChJMaWZlY3ljbGVDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIsgFChBGb3JnZUNhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSNgoMY3JlYXRlX2lzc3VlGAIgASgLMh4uY29tcGFzcy52MS5DcmVhdGVJc3N1ZVJlcXVlc3RIABI9ChBjb21tZW50X29uX2lzc3VlGAMgASgLMiEuY29tcGFzcy52MS5Db21tZW50T25Jc3N1ZVJlcXVlc3RIABIwCglnZXRfaXNzdWUYBCABKAsyGy5jb21wYXNzLnYxLkdldElzc3VlUmVxdWVzdEgAEjQKC2xpc3RfaXNzdWVzGAUgASgLMh0uY29tcGFzcy52MS5MaXN0SXNzdWVzUmVxdWVzdEgAEkMKE2NyZWF0ZV9wdWxsX3JlcXVlc3QYBiABKAsyJC5jb21wYXNzLnYxLkNyZWF0ZVB1bGxSZXF1ZXN0UmVxdWVzdEgAEkoKF2NvbW1lbnRfb25fcHVsbF9yZXF1ZXN0GAcgASgLMicuY29tcGFzcy52MS5Db21tZW50T25QdWxsUmVxdWVzdFJlcXVlc3RIABI9ChBnZXRfcHVsbF9yZXF1ZXN0GAggASgLMiEuY29tcGFzcy52MS5HZXRQdWxsUmVxdWVzdFJlcXVlc3RIABI2CglzdWJzY3JpYmUYCSABKAsyIS5jb21wYXNzLnYxLlN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjoKC3Vuc3Vic2NyaWJlGAogASgLMiMuY29tcGFzcy52MS5VbnN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjgKDXN1Ym1pdF9yZXZpZXcYCyABKAsyHy5jb21wYXNzLnYxLlN1Ym1pdFJldmlld1JlcXVlc3RIABIjCgVmb3JnZRgMIAEoCzIULmNvbXBhc3MudjEuRm9yZ2VSZWYSGQoRY2xpZW50X3JlcXVlc3RfaWQYDSABKAlCBgoEY2FsbCLgAwoPRm9yZ2VDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSIgoFaXNzdWUYAiABKAsyES5jb21wYXNzLnYxLklzc3VlSAASLwoNaXNzdWVfY29tbWVudBgDIAEoCzIWLmNvbXBhc3MudjEuQ29tbWVudFJlZkgAEjAKBmlzc3VlcxgEIAEoCzIeLmNvbXBhc3MudjEuTGlzdElzc3Vlc1Jlc3BvbnNlSAASLwoMcHVsbF9yZXF1ZXN0GAUgASgLMhcuY29tcGFzcy52MS5QdWxsUmVxdWVzdEgAEiwKCnByX2NvbW1lbnQYBiABKAsyFi5jb21wYXNzLnYxLkNvbW1lbnRSZWZIABI4CgpzdWJzY3JpYmVkGAcgASgLMiIuY29tcGFzcy52MS5TdWJzY3JpYmVGb3JnZVJlc3BvbnNlSAASPAoMdW5zdWJzY3JpYmVkGAggASgLMiQuY29tcGFzcy52MS5VbnN1YnNjcmliZUZvcmdlUmVzcG9uc2VIABIrCgVlcnJvchgJIAEoCzIaLmNvbXBhc3MudjEuRm9yZ2VDYWxsRXJyb3JIABInCgZyZXZpZXcYCiABKAsyFS5jb21wYXNzLnYxLlJldmlld1JlZkgAQggKBnJlc3VsdCJHCg5Gb3JnZUNhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkSFgoOcmV0cnlfYWZ0ZXJfbXMYAyABKA0iTwoSQ3JlYXRlSXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFdGl0bGUYAiABKAkSDAoEYm9keRgDIAEoCRIOCgZsYWJlbHMYBCADKAkiSQoVQ29tbWVudE9uSXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSFAoMaXNzdWVfbnVtYmVyGAIgASgEEgwKBGJvZHkYAyABKAkiNQoPR2V0SXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSFAoMaXNzdWVfbnVtYmVyGAIgASgEIk8KEUxpc3RJc3N1ZXNSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFc3RhdGUYAiABKAkSDgoGbGFiZWxzGAMgAygJEg0KBWxpbWl0GAQgASgNIjcKEkxpc3RJc3N1ZXNSZXNwb25zZRIhCgZpc3N1ZXMYASADKAsyES5jb21wYXNzLnYxLklzc3VlIngKGENyZWF0ZVB1bGxSZXF1ZXN0UmVxdWVzdBIMCgRyZXBvGAEgASgJEg0KBXRpdGxlGAIgASgJEgwKBGJvZHkYAyABKAkSEAoIaGVhZF9yZWYYBCABKAkSEAoIYmFzZV9yZWYYBSABKAkSDQoFZHJhZnQYBiABKAgiTgobQ29tbWVudE9uUHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSEwoLcHVsbF9udW1iZXIYAiABKAQSDAoEYm9keRgDIAEoCSI6ChVHZXRQdWxsUmVxdWVzdFJlcXVlc3QSDAoEcmVwbxgBIAEoCRITCgtwdWxsX251bWJlchgCIAEoBCKJAQoTU3VibWl0UmV2aWV3UmVxdWVzdBIMCgRyZXBvGAEgASgJEhMKC3B1bGxfbnVtYmVyGAIgASgEEg8KB3ZlcmRpY3QYAyABKAkSDAoEYm9keRgEIAEoCRIwCghjb21tZW50cxgFIAMoCzIeLmNvbXBhc3MudjEuUmV2aWV3Q29tbWVudElucHV0IkwKElJldmlld0NvbW1lbnRJbnB1dBIMCgRwYXRoGAEgASgJEgwKBGxpbmUYAiABKA0SDAoEc2lkZRgDIAEoCRIMCgRib2R5GAQgASgJIqYBChVTdWJzY3JpYmVGb3JnZVJlcXVlc3QSDAoEcmVwbxgBIAEoCRIrCgRraW5kGAIgASgOMh0uY29tcGFzcy52MS5Gb3JnZUFydGlmYWN0S2luZBIOCgZudW1iZXIYAyABKAQSMQoFc2NvcGUYBCABKA4yIi5jb21wYXNzLnYxLkZvcmdlU3Vic2NyaXB0aW9uU2NvcGUSDwoHcHJvamVjdBgFIAEoCSIxChZTdWJzY3JpYmVGb3JnZVJlc3BvbnNlEhcKD3N1YnNjcmlwdGlvbl9pZBgBIAEoCSIyChdVbnN1YnNjcmliZUZvcmdlUmVxdWVzdBIXCg9zdWJzY3JpcHRpb25faWQYASABKAkiGgoYVW5zdWJzY3JpYmVGb3JnZVJlc3BvbnNlImgKEEJvYXJkQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRI7Cg9zZXRfaXNzdWVfc3RhdGUYAiABKAsyIC5jb21wYXNzLnYxLlNldElzc3VlU3RhdGVSZXF1ZXN0SABCBgoEY2FsbCJPChRTZXRJc3N1ZVN0YXRlUmVxdWVzdBIQCghpc3N1ZV9pZBgBIAEoCRIlCgVzdGF0ZRgCIAEoDjIWLmNvbXBhc3MudjEuSXNzdWVTdGF0ZSI5ChVTZXRJc3N1ZVN0YXRlUmVzcG9uc2USIAoFaXNzdWUYASABKAsyES5jb21wYXNzLnYxLklzc3VlIpcBCg9Cb2FyZENhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRI8Cg9zZXRfaXNzdWVfc3RhdGUYAiABKAsyIS5jb21wYXNzLnYxLlNldElzc3VlU3RhdGVSZXNwb25zZUgAEisKBWVycm9yGAMgASgLMhouY29tcGFzcy52MS5Cb2FyZENhbGxFcnJvckgAQggKBnJlc3VsdCIvCg5Cb2FyZENhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiPAoTUHVibGlzaEZyYW1lUmVxdWVzdBIlCgVmcmFtZRgBIAEoCzIWLmNvbXBhc3MudjEuQWdlbnRGcmFtZSIWChRQdWJsaXNoRnJhbWVSZXNwb25zZSJeChxQb3N0Q29udmVyc2F0aW9uRnJhbWVSZXF1ZXN0EiUKBWZyYW1lGAEgASgLMhYuY29tcGFzcy52MS5BZ2VudEZyYW1lEhcKD2lkZW1wb3RlbmN5X2tleRgCIAEoCSIfCh1Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXNwb25zZSIZChdDb250cm9sU3Vic2NyaWJlUmVxdWVzdCqRAQoWRm9yZ2VTdWJzY3JpcHRpb25TY29wZRIoCiRGT1JHRV9TVUJTQ1JJUFRJT05fU0NPUEVfVU5TUEVDSUZJRUQQABIlCiFGT1JHRV9TVUJTQ1JJUFRJT05fU0NPUEVfQVJUSUZBQ1QQARImCiJGT1JHRV9TVUJTQ1JJUFRJT05fU0NPUEVfQ09OVEFJTkVSEAIytAQKDEFnZW50R2F0ZXdheRJCCgVDb21tcxIcLmNvbXBhc3MudjEuQ29tbXNDYWxsUmVxdWVzdBobLmNvbXBhc3MudjEuQ29tbXNDYWxsUmVzdWx0Ek4KCUxpZmVjeWNsZRIgLmNvbXBhc3MudjEuTGlmZWN5Y2xlQ2FsbFJlcXVlc3QaHy5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxSZXN1bHQSTgoHUHVibGlzaBIfLmNvbXBhc3MudjEuUHVibGlzaEZyYW1lUmVxdWVzdBogLmNvbXBhc3MudjEuUHVibGlzaEZyYW1lUmVzcG9uc2UoARJsChVQb3N0Q29udmVyc2F0aW9uRnJhbWUSKC5jb21wYXNzLnYxLlBvc3RDb252ZXJzYXRpb25GcmFtZVJlcXVlc3QaKS5jb21wYXNzLnYxLlBvc3RDb252ZXJzYXRpb25GcmFtZVJlc3BvbnNlEkoKB0NvbnRyb2wSIy5jb21wYXNzLnYxLkNvbnRyb2xTdWJzY3JpYmVSZXF1ZXN0GhguY29tcGFzcy52MS5BZ2VudENvbnRyb2wwARJCCgVGb3JnZRIcLmNvbXBhc3MudjEuRm9yZ2VDYWxsUmVxdWVzdBobLmNvbXBhc3MudjEuRm9yZ2VDYWxsUmVzdWx0EkIKBUJvYXJkEhwuY29tcGFzcy52MS5Cb2FyZENhbGxSZXF1ZXN0GhsuY29tcGFzcy52MS5Cb2FyZENhbGxSZXN1bHRiBnByb3RvMw", [file_compass_v1_comms, file_compass_v1_agent, file_compass_v1_compass, file_compass_v1_forge]); /** * One agent-initiated comms call. `call_id` is the agent-minted correlation id @@ -1020,14 +1020,12 @@ export const ReviewCommentInputSchema: GenMessage = /*@__PUR messageDesc(file_compass_v1_agent_gateway, 24); /** - * Subscribe/unsubscribe a forge artifact for change notifications (DL-053). The - * notification payload is ForgeNotification (forge.proto), delivered on the - * Sessions -> AgentGateway.Control push path. - * * @generated from message compass.v1.SubscribeForgeRequest */ export type SubscribeForgeRequest = Message<"compass.v1.SubscribeForgeRequest"> & { /** + * GitHub owner/name; Linear team key + * * @generated from field: string repo = 1; */ repo: string; @@ -1038,9 +1036,25 @@ export type SubscribeForgeRequest = Message<"compass.v1.SubscribeForgeRequest"> kind: ForgeArtifactKind; /** + * ARTIFACT only; MUST be 0 under CONTAINER + * * @generated from field: uint64 number = 3; */ number: bigint; + + /** + * additive; UNSPECIFIED = ARTIFACT + * + * @generated from field: compass.v1.ForgeSubscriptionScope scope = 4; + */ + scope: ForgeSubscriptionScope; + + /** + * CONTAINER on LINEAR only: the project id + * + * @generated from field: string project = 5; + */ + project: string; }; /** @@ -1352,6 +1366,46 @@ export type ControlSubscribeRequest = Message<"compass.v1.ControlSubscribeReques export const ControlSubscribeRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_compass_v1_agent_gateway, 38); +/** + * Subscribe/unsubscribe a forge artifact for change notifications (DL-053). The + * notification payload is ForgeNotification (forge.proto), delivered on the + * Sessions -> AgentGateway.Control push path. + * The subscription scope (W2, decided (b): the number=0 sentinel is dropped; + * OQ-1 ruled (i)). ARTIFACT addresses one issue/PR; CONTAINER addresses the + * whole repo on GitHub or a PROJECT on Linear. Zero is treated as ARTIFACT for + * pre-scope callers. + * + * @generated from enum compass.v1.ForgeSubscriptionScope + */ +export enum ForgeSubscriptionScope { + /** + * treated as ARTIFACT (pre-scope callers) + * + * @generated from enum value: FORGE_SUBSCRIPTION_SCOPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * one issue/PR; number REQUIRED (> 0) + * + * @generated from enum value: FORGE_SUBSCRIPTION_SCOPE_ARTIFACT = 1; + */ + ARTIFACT = 1, + + /** + * GitHub: the whole repo; Linear: a PROJECT + * + * @generated from enum value: FORGE_SUBSCRIPTION_SCOPE_CONTAINER = 2; + */ + CONTAINER = 2, +} + +/** + * Describes the enum compass.v1.ForgeSubscriptionScope. + */ +export const ForgeSubscriptionScopeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_compass_v1_agent_gateway, 0); + /** * agent -> Runner, unary. The agent emits a correlated comms call; the Runner * forwards it to the Server (RelayCommsCall) tagged with the session it diff --git a/packages/compass-agent/src/gen/compass/v1/agent_pb.ts b/packages/compass-agent/src/gen/compass/v1/agent_pb.ts index 6f111289d..b2a636296 100644 --- a/packages/compass-agent/src/gen/compass/v1/agent_pb.ts +++ b/packages/compass-agent/src/gen/compass/v1/agent_pb.ts @@ -35,7 +35,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file compass/v1/agent.proto. */ export const file_compass_v1_agent: GenFile = /*@__PURE__*/ - fileDesc("ChZjb21wYXNzL3YxL2FnZW50LnByb3RvEgpjb21wYXNzLnYxIpkCCgpBZ2VudEZyYW1lEisKB3Nlc3Npb24YAyABKAsyGC5jb21wYXNzLnYxLlNlc3Npb25GcmFtZUgAEjwKE3JlcGxheV9jb21wbGV0ZV9hY2sYBCABKAsyHS5jb21wYXNzLnYxLlJlcGxheUNvbXBsZXRlQWNrSAASLQoLY29udHJvbF9hY2sYBSABKAsyFi5jb21wYXNzLnYxLkNvbnRyb2xBY2tIABIvCgxkZWxpdmVyeV9hY2sYBiABKAsyFy5jb21wYXNzLnYxLkRlbGl2ZXJ5QWNrSAASNwoQdHJhbnNjcmlwdF9lbnRyeRgHIAEoCzIbLmNvbXBhc3MudjEuVHJhbnNjcmlwdEVudHJ5SABCBwoFZnJhbWUiTAoPVHJhbnNjcmlwdEVudHJ5EhIKCmVudHJ5X2pzb24YASABKAkSEgoKY2hlY2twb2ludBgCIAEoCBIRCgllbnRyeV9zZXEYAyABKAQiawoMU2Vzc2lvbkZyYW1lEiwKBXN0YXRlGAEgASgOMh0uY29tcGFzcy52MS5BZ2VudFNlc3Npb25TdGF0ZRItCgt0eXBlZF9ldmVudBgCIAEoCzIYLmNvbXBhc3MudjEuU2Vzc2lvbkV2ZW50IoYDCgxBZ2VudENvbnRyb2wSEwoLY29udHJvbF9zZXEYCCABKAQSKwoGcHJvbXB0GAEgASgLMhkuY29tcGFzcy52MS5Qcm9tcHRDb250cm9sSAASKQoFc3RlZXIYAiABKAsyGC5jb21wYXNzLnYxLlN0ZWVyQ29udHJvbEgAEi0KB2RlbGl2ZXIYAyABKAsyGi5jb21wYXNzLnYxLkRlbGl2ZXJDb250cm9sSAASKwoGY29uZmlnGAUgASgLMhkuY29tcGFzcy52MS5Db25maWdDb250cm9sSAASLgoGcmVwbGF5GAYgASgLMhwuY29tcGFzcy52MS5UcmFuc2NyaXB0UmVwbGF5SAASNQoPcmVwbGF5X2NvbXBsZXRlGAcgASgLMhouY29tcGFzcy52MS5SZXBsYXlDb21wbGV0ZUgAEjsKEmZvcmdlX25vdGlmaWNhdGlvbhgJIAEoCzIdLmNvbXBhc3MudjEuRm9yZ2VOb3RpZmljYXRpb25IAEIJCgdjb250cm9sIh4KDVByb21wdENvbnRyb2wSDQoFaW5wdXQYASABKAkiEAoOUmVwbGF5Q29tcGxldGUiSQoMU3RlZXJDb250cm9sEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2USEwoLZnJvbV9oYW5kbGUYAiABKAkiEgoQVHJhbnNjcmlwdFJlcGxheSIPCg1Db25maWdDb250cm9sIl8KDkRlbGl2ZXJDb250cm9sEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2USEgoKdG9waWNfbmFtZRgCIAEoCRITCgtmcm9tX2hhbmRsZRgDIAEoCSIhCgtEZWxpdmVyeUFjaxISCgptZXNzYWdlX2lkGAEgASgJIhMKEVJlcGxheUNvbXBsZXRlQWNrIjYKCkNvbnRyb2xBY2sSEQoJYWNrZWRfc2VxGAEgASgEEhUKDWFwcGxpZWRfYWJvdmUYAiADKARiBnByb3RvMw", [file_compass_v1_comms, file_compass_v1_compass, file_compass_v1_forge]); + fileDesc("ChZjb21wYXNzL3YxL2FnZW50LnByb3RvEgpjb21wYXNzLnYxIt0CCgpBZ2VudEZyYW1lEisKB3Nlc3Npb24YAyABKAsyGC5jb21wYXNzLnYxLlNlc3Npb25GcmFtZUgAEjwKE3JlcGxheV9jb21wbGV0ZV9hY2sYBCABKAsyHS5jb21wYXNzLnYxLlJlcGxheUNvbXBsZXRlQWNrSAASLQoLY29udHJvbF9hY2sYBSABKAsyFi5jb21wYXNzLnYxLkNvbnRyb2xBY2tIABIvCgxkZWxpdmVyeV9hY2sYBiABKAsyFy5jb21wYXNzLnYxLkRlbGl2ZXJ5QWNrSAASNwoQdHJhbnNjcmlwdF9lbnRyeRgHIAEoCzIbLmNvbXBhc3MudjEuVHJhbnNjcmlwdEVudHJ5SAASQgoWZm9yZ2Vfbm90aWZpY2F0aW9uX2FjaxgIIAEoCzIgLmNvbXBhc3MudjEuRm9yZ2VOb3RpZmljYXRpb25BY2tIAEIHCgVmcmFtZSJMCg9UcmFuc2NyaXB0RW50cnkSEgoKZW50cnlfanNvbhgBIAEoCRISCgpjaGVja3BvaW50GAIgASgIEhEKCWVudHJ5X3NlcRgDIAEoBCJrCgxTZXNzaW9uRnJhbWUSLAoFc3RhdGUYASABKA4yHS5jb21wYXNzLnYxLkFnZW50U2Vzc2lvblN0YXRlEi0KC3R5cGVkX2V2ZW50GAIgASgLMhguY29tcGFzcy52MS5TZXNzaW9uRXZlbnQihgMKDEFnZW50Q29udHJvbBITCgtjb250cm9sX3NlcRgIIAEoBBIrCgZwcm9tcHQYASABKAsyGS5jb21wYXNzLnYxLlByb21wdENvbnRyb2xIABIpCgVzdGVlchgCIAEoCzIYLmNvbXBhc3MudjEuU3RlZXJDb250cm9sSAASLQoHZGVsaXZlchgDIAEoCzIaLmNvbXBhc3MudjEuRGVsaXZlckNvbnRyb2xIABIrCgZjb25maWcYBSABKAsyGS5jb21wYXNzLnYxLkNvbmZpZ0NvbnRyb2xIABIuCgZyZXBsYXkYBiABKAsyHC5jb21wYXNzLnYxLlRyYW5zY3JpcHRSZXBsYXlIABI1Cg9yZXBsYXlfY29tcGxldGUYByABKAsyGi5jb21wYXNzLnYxLlJlcGxheUNvbXBsZXRlSAASOwoSZm9yZ2Vfbm90aWZpY2F0aW9uGAkgASgLMh0uY29tcGFzcy52MS5Gb3JnZU5vdGlmaWNhdGlvbkgAQgkKB2NvbnRyb2wiHgoNUHJvbXB0Q29udHJvbBINCgVpbnB1dBgBIAEoCSIQCg5SZXBsYXlDb21wbGV0ZSJJCgxTdGVlckNvbnRyb2wSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZRITCgtmcm9tX2hhbmRsZRgCIAEoCSISChBUcmFuc2NyaXB0UmVwbGF5Ig8KDUNvbmZpZ0NvbnRyb2wiXwoORGVsaXZlckNvbnRyb2wSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZRISCgp0b3BpY19uYW1lGAIgASgJEhMKC2Zyb21faGFuZGxlGAMgASgJIiEKC0RlbGl2ZXJ5QWNrEhIKCm1lc3NhZ2VfaWQYASABKAkiQQoURm9yZ2VOb3RpZmljYXRpb25BY2sSFwoPc3Vic2NyaXB0aW9uX2lkGAEgASgJEhAKCHJldmlzaW9uGAIgASgJIhMKEVJlcGxheUNvbXBsZXRlQWNrIjYKCkNvbnRyb2xBY2sSEQoJYWNrZWRfc2VxGAEgASgEEhUKDWFwcGxpZWRfYWJvdmUYAiADKARiBnByb3RvMw", [file_compass_v1_comms, file_compass_v1_compass, file_compass_v1_forge]); /** * The agent's stdout envelope: one discriminated frame per newline-delimited @@ -126,6 +126,18 @@ export type AgentFrame = Message<"compass.v1.AgentFrame"> & { */ value: TranscriptEntry; case: "transcriptEntry"; + } | { + /** + * forge_notification_ack — the agent's per-notification receipt for a + * ForgeNotification pushed down the session (W3; forge sibling of + * delivery_ack). Emitted at turn-end flush (T6), applied by a hub ack + * arm beside deliverAck (T7): on receipt the Server advances the + * subscription's delivered_revision to the acked revision. + * + * @generated from field: compass.v1.ForgeNotificationAck forge_notification_ack = 8; + */ + value: ForgeNotificationAck; + case: "forgeNotificationAck"; } | { case: undefined; value?: undefined }; }; @@ -498,6 +510,37 @@ export type DeliveryAck = Message<"compass.v1.DeliveryAck"> & { export const DeliveryAckSchema: GenMessage = /*@__PURE__*/ messageDesc(file_compass_v1_agent, 10); +/** + * ForgeNotificationAck — the agent's per-notification delivery receipt (W3), an + * AgentFrame oneof variant riding the Publish spine beside DeliveryAck. Where + * DeliveryAck correlates a comms delivery by message_id, this correlates a forge + * notification by subscription_id and carries the notified `revision` (the + * advance target): on receipt the Server advances that subscription's + * delivered_revision (T7 hub ack arm; store AdvanceForgeDeliveredRevision). + * + * @generated from message compass.v1.ForgeNotificationAck + */ +export type ForgeNotificationAck = Message<"compass.v1.ForgeNotificationAck"> & { + /** + * @generated from field: string subscription_id = 1; + */ + subscriptionId: string; + + /** + * the notified revision; the advance target + * + * @generated from field: string revision = 2; + */ + revision: string; +}; + +/** + * Describes the message compass.v1.ForgeNotificationAck. + * Use `create(ForgeNotificationAckSchema)` to create a new message. + */ +export const ForgeNotificationAckSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_compass_v1_agent, 11); + /** * Two agent -> Runner control-plane ACK frames, added as AgentFrame oneof * variants above (riding the loss-tolerable Publish spine beside DeliveryAck, @@ -514,7 +557,7 @@ export type ReplayCompleteAck = Message<"compass.v1.ReplayCompleteAck"> & { * Use `create(ReplayCompleteAckSchema)` to create a new message. */ export const ReplayCompleteAckSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent, 11); + messageDesc(file_compass_v1_agent, 12); /** * @generated from message compass.v1.ControlAck @@ -542,5 +585,5 @@ export type ControlAck = Message<"compass.v1.ControlAck"> & { * Use `create(ControlAckSchema)` to create a new message. */ export const ControlAckSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent, 12); + messageDesc(file_compass_v1_agent, 13); diff --git a/packages/compass-agent/src/gen/compass/v1/forge_pb.ts b/packages/compass-agent/src/gen/compass/v1/forge_pb.ts index 173537309..e99e85fca 100644 --- a/packages/compass-agent/src/gen/compass/v1/forge_pb.ts +++ b/packages/compass-agent/src/gen/compass/v1/forge_pb.ts @@ -42,7 +42,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file compass/v1/forge.proto. */ export const file_compass_v1_forge: GenFile = /*@__PURE__*/ - fileDesc("ChZjb21wYXNzL3YxL2ZvcmdlLnByb3RvEgpjb21wYXNzLnYxIn8KCkNvbW1lbnRSZWYSCwoDdXJsGAEgASgJEhIKCmNvbW1lbnRfaWQYAiABKAQSDAoEYm9keRgDIAEoCRIVCg1mb3JnZV9hY2NvdW50GAQgASgJEisKBWFnZW50GAUgASgLMhwuY29tcGFzcy52MS5BZ2VudEF0dHJpYnV0aW9uIjwKCVJldmlld1JlZhILCgN1cmwYASABKAkSEQoJcmV2aWV3X2lkGAIgASgEEg8KB3ZlcmRpY3QYAyABKAkivwIKEUZvcmdlTm90aWZpY2F0aW9uEhcKD3N1YnNjcmlwdGlvbl9pZBgBIAEoCRIjCgVmb3JnZRgCIAEoCzIULmNvbXBhc3MudjEuRm9yZ2VSZWYSDAoEcmVwbxgDIAEoCRIrCgRraW5kGAQgASgOMh0uY29tcGFzcy52MS5Gb3JnZUFydGlmYWN0S2luZBIOCgZudW1iZXIYBSABKAQSCwoDdXJsGAYgASgJEjEKBmNoYW5nZRgHIAEoDjIhLmNvbXBhc3MudjEuRm9yZ2VOb3RpZmljYXRpb25LaW5kEicKB2NvbW1lbnQYCCABKAsyFi5jb21wYXNzLnYxLkNvbW1lbnRSZWYSKQoGY2hlY2tzGAkgASgLMhkuY29tcGFzcy52MS5DaGVja3NTdW1tYXJ5Eg0KBXN0YXRlGAogASgJKn0KEUZvcmdlQXJ0aWZhY3RLaW5kEiMKH0ZPUkdFX0FSVElGQUNUX0tJTkRfVU5TUEVDSUZJRUQQABIdChlGT1JHRV9BUlRJRkFDVF9LSU5EX0lTU1VFEAESJAogRk9SR0VfQVJUSUZBQ1RfS0lORF9QVUxMX1JFUVVFU1QQAirQAQoVRm9yZ2VOb3RpZmljYXRpb25LaW5kEicKI0ZPUkdFX05PVElGSUNBVElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASIwofRk9SR0VfTk9USUZJQ0FUSU9OX0tJTkRfQ09NTUVOVBABEiEKHUZPUkdFX05PVElGSUNBVElPTl9LSU5EX1NUQVRFEAISIgoeRk9SR0VfTk9USUZJQ0FUSU9OX0tJTkRfVVBEQVRFEAMSIgoeRk9SR0VfTk9USUZJQ0FUSU9OX0tJTkRfQ0hFQ0tTEARiBnByb3RvMw", [file_compass_v1_compass]); + fileDesc("ChZjb21wYXNzL3YxL2ZvcmdlLnByb3RvEgpjb21wYXNzLnYxIn8KCkNvbW1lbnRSZWYSCwoDdXJsGAEgASgJEhIKCmNvbW1lbnRfaWQYAiABKAQSDAoEYm9keRgDIAEoCRIVCg1mb3JnZV9hY2NvdW50GAQgASgJEisKBWFnZW50GAUgASgLMhwuY29tcGFzcy52MS5BZ2VudEF0dHJpYnV0aW9uIjwKCVJldmlld1JlZhILCgN1cmwYASABKAkSEQoJcmV2aWV3X2lkGAIgASgEEg8KB3ZlcmRpY3QYAyABKAki0QIKEUZvcmdlTm90aWZpY2F0aW9uEhcKD3N1YnNjcmlwdGlvbl9pZBgBIAEoCRIjCgVmb3JnZRgCIAEoCzIULmNvbXBhc3MudjEuRm9yZ2VSZWYSDAoEcmVwbxgDIAEoCRIrCgRraW5kGAQgASgOMh0uY29tcGFzcy52MS5Gb3JnZUFydGlmYWN0S2luZBIOCgZudW1iZXIYBSABKAQSCwoDdXJsGAYgASgJEjEKBmNoYW5nZRgHIAEoDjIhLmNvbXBhc3MudjEuRm9yZ2VOb3RpZmljYXRpb25LaW5kEicKB2NvbW1lbnQYCCABKAsyFi5jb21wYXNzLnYxLkNvbW1lbnRSZWYSKQoGY2hlY2tzGAkgASgLMhkuY29tcGFzcy52MS5DaGVja3NTdW1tYXJ5Eg0KBXN0YXRlGAogASgJEhAKCHJldmlzaW9uGAsgASgJKn0KEUZvcmdlQXJ0aWZhY3RLaW5kEiMKH0ZPUkdFX0FSVElGQUNUX0tJTkRfVU5TUEVDSUZJRUQQABIdChlGT1JHRV9BUlRJRkFDVF9LSU5EX0lTU1VFEAESJAogRk9SR0VfQVJUSUZBQ1RfS0lORF9QVUxMX1JFUVVFU1QQAiqYAgoVRm9yZ2VOb3RpZmljYXRpb25LaW5kEicKI0ZPUkdFX05PVElGSUNBVElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASIwofRk9SR0VfTk9USUZJQ0FUSU9OX0tJTkRfQ09NTUVOVBABEiEKHUZPUkdFX05PVElGSUNBVElPTl9LSU5EX1NUQVRFEAISIgoeRk9SR0VfTk9USUZJQ0FUSU9OX0tJTkRfVVBEQVRFEAMSIgoeRk9SR0VfTk9USUZJQ0FUSU9OX0tJTkRfQ0hFQ0tTEAQSIgoeRk9SR0VfTk9USUZJQ0FUSU9OX0tJTkRfUkVWSUVXEAUSIgoeRk9SR0VfTk9USUZJQ0FUSU9OX0tJTkRfT1BFTkVEEAZiBnByb3RvMw", [file_compass_v1_compass]); /** * A reference to a forge comment, carried on a comment write result and on a @@ -203,6 +203,16 @@ export type ForgeNotification = Message<"compass.v1.ForgeNotification"> & { * @generated from field: string state = 10; */ state: string; + + /** + * The whole-artifact snapshot digest this notification reflects (T4's + * SnapshotRevision, computed at ApplyEvent). The agent echoes it back in + * ForgeNotificationAck.revision at turn-end flush; the Server advances the + * subscription's delivered_revision to it (two-cursor split, DL-053/DL-266). + * + * @generated from field: string revision = 11; + */ + revision: string; }; /** @@ -280,6 +290,22 @@ export enum ForgeNotificationKind { * @generated from enum value: FORGE_NOTIFICATION_KIND_CHECKS = 4; */ CHECKS = 4, + + /** + * a submitted PR review; comment carries + * + * @generated from enum value: FORGE_NOTIFICATION_KIND_REVIEW = 5; + */ + REVIEW = 5, + + /** + * body+url, state the verdict + * + * container-scope: a new artifact; the + * + * @generated from enum value: FORGE_NOTIFICATION_KIND_OPENED = 6; + */ + OPENED = 6, } /** diff --git a/proto/compass/v1/agent.proto b/proto/compass/v1/agent.proto index 49865f4de..02deefc25 100644 --- a/proto/compass/v1/agent.proto +++ b/proto/compass/v1/agent.proto @@ -82,6 +82,12 @@ message AgentFrame { // container-ephemeral. Reconstructed into a session-JSONL body on // resume (T4/T5). TranscriptEntry transcript_entry = 7; + // forge_notification_ack — the agent's per-notification receipt for a + // ForgeNotification pushed down the session (W3; forge sibling of + // delivery_ack). Emitted at turn-end flush (T6), applied by a hub ack + // arm beside deliverAck (T7): on receipt the Server advances the + // subscription's delivered_revision to the acked revision. + ForgeNotificationAck forge_notification_ack = 8; } } @@ -238,6 +244,17 @@ message DeliveryAck { string message_id = 1; } +// ForgeNotificationAck — the agent's per-notification delivery receipt (W3), an +// AgentFrame oneof variant riding the Publish spine beside DeliveryAck. Where +// DeliveryAck correlates a comms delivery by message_id, this correlates a forge +// notification by subscription_id and carries the notified `revision` (the +// advance target): on receipt the Server advances that subscription's +// delivered_revision (T7 hub ack arm; store AdvanceForgeDeliveredRevision). +message ForgeNotificationAck { + string subscription_id = 1; + string revision = 2; // the notified revision; the advance target +} + // Two agent -> Runner control-plane ACK frames, added as AgentFrame oneof // variants above (riding the loss-tolerable Publish spine beside DeliveryAck, // the established frame-spine ack convention — consolidation OQ-4(i) + amended diff --git a/proto/compass/v1/agent_gateway.proto b/proto/compass/v1/agent_gateway.proto index 61d94c03b..6732ef314 100644 --- a/proto/compass/v1/agent_gateway.proto +++ b/proto/compass/v1/agent_gateway.proto @@ -337,10 +337,21 @@ message ReviewCommentInput { // Subscribe/unsubscribe a forge artifact for change notifications (DL-053). The // notification payload is ForgeNotification (forge.proto), delivered on the // Sessions -> AgentGateway.Control push path. +// The subscription scope (W2, decided (b): the number=0 sentinel is dropped; +// OQ-1 ruled (i)). ARTIFACT addresses one issue/PR; CONTAINER addresses the +// whole repo on GitHub or a PROJECT on Linear. Zero is treated as ARTIFACT for +// pre-scope callers. +enum ForgeSubscriptionScope { + FORGE_SUBSCRIPTION_SCOPE_UNSPECIFIED = 0; // treated as ARTIFACT (pre-scope callers) + FORGE_SUBSCRIPTION_SCOPE_ARTIFACT = 1; // one issue/PR; number REQUIRED (> 0) + FORGE_SUBSCRIPTION_SCOPE_CONTAINER = 2; // GitHub: the whole repo; Linear: a PROJECT +} message SubscribeForgeRequest { - string repo = 1; + string repo = 1; // GitHub owner/name; Linear team key ForgeArtifactKind kind = 2; - uint64 number = 3; + uint64 number = 3; // ARTIFACT only; MUST be 0 under CONTAINER + ForgeSubscriptionScope scope = 4; // additive; UNSPECIFIED = ARTIFACT + string project = 5; // CONTAINER on LINEAR only: the project id } message SubscribeForgeResponse { string subscription_id = 1; diff --git a/proto/compass/v1/forge.proto b/proto/compass/v1/forge.proto index b4dc95530..d58ef5fbf 100644 --- a/proto/compass/v1/forge.proto +++ b/proto/compass/v1/forge.proto @@ -92,6 +92,11 @@ message ForgeNotification { ChecksSummary checks = 9; // Set for STATE: the new forge state string ("closed", "merged", …). string state = 10; + // The whole-artifact snapshot digest this notification reflects (T4's + // SnapshotRevision, computed at ApplyEvent). The agent echoes it back in + // ForgeNotificationAck.revision at turn-end flush; the Server advances the + // subscription's delivered_revision to it (two-cursor split, DL-053/DL-266). + string revision = 11; } enum ForgeNotificationKind { FORGE_NOTIFICATION_KIND_UNSPECIFIED = 0; @@ -99,4 +104,8 @@ enum ForgeNotificationKind { FORGE_NOTIFICATION_KIND_STATE = 2; // opened/closed/merged/reopened FORGE_NOTIFICATION_KIND_UPDATE = 3; // title/body/labels edited FORGE_NOTIFICATION_KIND_CHECKS = 4; // CI or status-check state changed + FORGE_NOTIFICATION_KIND_REVIEW = 5; // a submitted PR review; comment carries + // body+url, state the verdict + FORGE_NOTIFICATION_KIND_OPENED = 6; // container-scope: a new artifact; the + // envelope's number/url address it }