From 94ba3cb983e530088d741957542b06156f716eca Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 14 Oct 2026 23:18:29 -0400 Subject: [PATCH 1/8] feat(mirror): agent forge_mirror_messages tool with real-session attribution (RIG-5174) Adds the mirror_messages forge arm and the forge_mirror_messages agent tool. An agent mirrors one visible topic or 1-50 selected messages to an auto-mode issue or PR as one comment; a retry with the same client_request_id returns the first result and never posts twice. - Every agent mirror post stamps the real session: the direct call's session, or for queued ask mirrors the relay-verified posting session (RIG-5431). An agent row with no session is refused, never posted as `mirror`. - forge_mirrors gains session_id and source_payload; a redrive after restart rebuilds the body from the payload and re-checks visibility (RIG-5439). - The source channel is a channel name resolved for the caller (RIG-5430). Co-authored-by: Matt Wilkinson --- docs/concepts/tools.md | 17 +- docs/specs/product/compass.md | 56 ++ .../gen/compass/v1/agent_gateway.pb.go | 601 ++++++++++-------- go/internal/mirror/drive.go | 6 +- go/internal/mirror/drive_pgtest_test.go | 28 +- go/internal/mirror/render_test.go | 2 +- go/internal/runnerhub/relay_comms.go | 2 + .../runnerhub/relay_tenant_pgtest_test.go | 63 ++ go/internal/store/context.go | 15 + go/internal/store/db/forge_mirrors.sql.go | 73 ++- go/internal/store/db/models.go | 2 + go/internal/store/db/querier.go | 2 +- go/internal/store/delivery_reads.go | 13 + go/internal/store/forge_mirrors.go | 17 +- .../store/forge_mirrors_pgtest_test.go | 48 ++ go/internal/store/migrations/0001_init.sql | 2 + go/internal/store/queries/forge_mirrors.sql | 27 +- go/server/forge.go | 2 + go/server/forge_mirror.go | 229 ++++--- go/server/forge_mirror_agent.go | 213 +++++++ go/server/forge_mirror_agent_pgtest_test.go | 356 +++++++++++ go/server/forge_mirror_pgtest_test.go | 11 +- go/server/forge_test.go | 52 +- packages/compass-agent/src/cli.test.ts | 4 +- packages/compass-agent/src/compassv1.ts | 5 +- packages/compass-agent/src/forge.test.ts | 144 ++++- packages/compass-agent/src/forge.ts | 71 ++- .../src/gen/compass/v1/agent_gateway_pb.ts | 124 ++-- proto/compass/v1/agent_gateway.proto | 27 +- 29 files changed, 1746 insertions(+), 466 deletions(-) create mode 100644 go/server/forge_mirror_agent.go create mode 100644 go/server/forge_mirror_agent_pgtest_test.go diff --git a/docs/concepts/tools.md b/docs/concepts/tools.md index c2f8adf1e..e399643e1 100644 --- a/docs/concepts/tools.md +++ b/docs/concepts/tools.md @@ -5,11 +5,9 @@ harness resolves against the server, distinct from any MCP tools a task might also carry. Each tool carries its own description at the call site; this doc is the higher-level map: what the set covers and the general flow of using it. -> **Keep this current as tools land.** The comms and lifecycle tools below have -> shipped and are documented here. The forge tools and the subscription tools -> are landing; as each lands, -> add it to the right group below and note its flow. A tool that has shipped but -> is not listed here is a documentation gap to close. +> **Keep this current as tools land.** The comms, lifecycle, and forge tools +> below have shipped and are documented here. A tool that has shipped but is +> not listed here is a documentation gap to close. ## Comms — how agents talk @@ -62,6 +60,15 @@ a subagent spawn. container + session). Spawning a child Manager needs operator approval first. - **`agents_despawn_peer`** — tear a standing agent down when its lane closes. +## Forge — working with issues and pull requests + +- **`forge_get_issue`, `forge_get_pull_request`, `forge_list_issues`** — read forge state. +- **`forge_comment_on_issue`, `forge_comment_on_pull_request`, `forge_submit_review`** — write comments and reviews under server-stamped agent attribution. +- **`forge_create_issue`, `forge_create_pull_request`** — create forge artifacts with idempotent retries. +- **`forge_transition_issue_state`, `forge_transition_pull_request_state`** — change artifact state. +- **`forge_subscribe`, `forge_unsubscribe`** — manage notifications for forge artifacts. +- **`forge_mirror_messages`** — mirror one visible topic or selected messages to an AUTO-enabled issue or pull request. Choose exactly one source. A topic mirrors its newest 50 messages; selected messages must come from one channel. An optional channel is a visible channel name, and an optional note appears above the messages. The Server checks source visibility, AUTO mode, repository scope, and idempotent delivery before posting. + ## The general flow 1. **Orient** — `compass_roster` to see who is around; read your home channel diff --git a/docs/specs/product/compass.md b/docs/specs/product/compass.md index 43fdb0441..7f65bc173 100644 --- a/docs/specs/product/compass.md +++ b/docs/specs/product/compass.md @@ -1057,6 +1057,62 @@ same ref SHALL be refused while the first user's mirror exists. Topic mirrors SHALL carry at most the newest 50 messages. If a topic has more than 50, the mirror SHALL state, "Showing the newest 50 messages." +### Requirement: Agent mirror tools may mirror visible messages to AUTO forge targets + +The agent `forge_mirror_messages` tool SHALL accept exactly one source: a topic +or one to fifty message IDs from one channel. The caller SHALL be able to see +the source channel. The tool SHALL check the target with the agent mirror policy +and SHALL post only when the target is in AUTO mode; MANUAL and OFF SHALL be +refused. The server SHALL render a topic's newest 50 messages, include the +optional note, drop whole oldest messages when the forge body limit requires it, +and state how many were dropped. Re-mirroring the same source to the same target +SHALL edit the existing comment. A completed retry with the same +`client_request_id` SHALL return the stored comment reference without posting +again; a pending retry SHALL return unavailable, and a failed retry SHALL +return its stored error. A target that is a tracked pull request SHALL return a +PR-comment result; another target SHALL return an issue-comment result. + +The optional `channel` selector SHALL be a channel name, never a channel ID. +When it is omitted, the server SHALL take the channel from the topic or the +messages. When it names a channel that does not hold the source, or one the +agent cannot see, the server SHALL return not-found. Every agent mirror post +SHALL name the agent's real posting session in its owner header; a row with no +session SHALL fail without posting. The server SHALL store the source on the +row, so a mirror still pending at a restart is redriven with the same body. +A redrive SHALL check visibility again, and SHALL fail the row without posting +when the agent can no longer see the source. + +#### Scenario: A retry returns the first mirror result + +- **Given** an agent mirror request that has already delivered a comment +- **When** the agent retries it with the same `client_request_id` +- **Then** the server returns the stored comment reference and the forge sees + only one post + +#### Scenario: A topic is mirrored again to the same target + +- **Given** a topic already mirrored to a forge target +- **When** the agent mirrors that topic to the same target again +- **Then** the server edits the existing comment instead of creating another + +#### Scenario: An agent cannot mirror to a MANUAL or OFF target + +- **Given** a forge target configured as MANUAL or OFF +- **When** an agent calls `forge_mirror_messages` for that target +- **Then** the server refuses the call without posting a comment + +#### Scenario: An agent cannot mirror messages from a channel it cannot see + +- **Given** a source topic or message set in a channel the agent cannot see +- **When** the agent calls `forge_mirror_messages` +- **Then** the server returns not-found without revealing the channel or messages + +#### Scenario: A pending mirror is redriven after a restart + +- **Given** an agent mirror row that was claimed but not posted before a restart +- **When** the restarted server drives the agent's open rows +- **Then** it posts the same body, stamped with the session that made the call + ### Requirement: Automatic mirroring follows the ask's explicit ref An ask with an explicit ref to an enabled AUTO coordinate SHALL be mirrored diff --git a/go/internal/gen/compass/v1/agent_gateway.pb.go b/go/internal/gen/compass/v1/agent_gateway.pb.go index ff8160e32..4dcee1b77 100644 --- a/go/internal/gen/compass/v1/agent_gateway.pb.go +++ b/go/internal/gen/compass/v1/agent_gateway.pb.go @@ -1322,6 +1322,7 @@ type ForgeCallRequest struct { // *ForgeCallRequest_Subscribe // *ForgeCallRequest_Unsubscribe // *ForgeCallRequest_SubmitReview + // *ForgeCallRequest_MirrorMessages // *ForgeCallRequest_TransitionIssueState // *ForgeCallRequest_TransitionPullRequestState Call isForgeCallRequest_Call `protobuf_oneof:"call"` @@ -1331,13 +1332,10 @@ type ForgeCallRequest struct { // selected provider's default host (A3) — never a panic, never a silent // provider fall-through. Forge *v1.ForgeRef `protobuf:"bytes,12,opt,name=forge,proto3" json:"forge,omitempty"` - // Whole-chain idempotency key for the CREATE arms (create_issue / - // create_pull_request) — the SpawnPeerRequest.client_request_id precedent - // (agent_gateway.proto:172 "whole-chain idempotency key (handler-level - // join + Provision dedup)"). A retried create with the same key returns the - // ORIGINAL artifact, never a duplicate. Distinct from call_id, which is - // correlation-only. Ignored on non-create arms. Field 13 is collision-free: - // call_id=1, oneof arms 2-11 and 14-15, forge=12. + // Whole-chain idempotency key for create and mirror_messages calls. A retry + // with the same key returns the original result without posting again. + // Distinct from call_id, which is correlation-only. Field 13 is collision-free: + // call_id=1, oneof arms 2-11, 14-16, forge=12. ClientRequestId string `protobuf:"bytes,13,opt,name=client_request_id,json=clientRequestId,proto3" json:"client_request_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1477,6 +1475,15 @@ func (x *ForgeCallRequest) GetSubmitReview() *SubmitReviewRequest { return nil } +func (x *ForgeCallRequest) GetMirrorMessages() *MirrorMessagesRequest { + if x != nil { + if x, ok := x.Call.(*ForgeCallRequest_MirrorMessages); ok { + return x.MirrorMessages + } + } + return nil +} + func (x *ForgeCallRequest) GetTransitionIssueState() *TransitionIssueStateRequest { if x != nil { if x, ok := x.Call.(*ForgeCallRequest_TransitionIssueState); ok { @@ -1553,6 +1560,10 @@ type ForgeCallRequest_SubmitReview struct { SubmitReview *SubmitReviewRequest `protobuf:"bytes,11,opt,name=submit_review,json=submitReview,proto3,oneof"` } +type ForgeCallRequest_MirrorMessages struct { + MirrorMessages *MirrorMessagesRequest `protobuf:"bytes,16,opt,name=mirror_messages,json=mirrorMessages,proto3,oneof"` +} + type ForgeCallRequest_TransitionIssueState struct { TransitionIssueState *TransitionIssueStateRequest `protobuf:"bytes,14,opt,name=transition_issue_state,json=transitionIssueState,proto3,oneof"` } @@ -1581,10 +1592,89 @@ func (*ForgeCallRequest_Unsubscribe) isForgeCallRequest_Call() {} func (*ForgeCallRequest_SubmitReview) isForgeCallRequest_Call() {} +func (*ForgeCallRequest_MirrorMessages) isForgeCallRequest_Call() {} + func (*ForgeCallRequest_TransitionIssueState) isForgeCallRequest_Call() {} func (*ForgeCallRequest_TransitionPullRequestState) isForgeCallRequest_Call() {} +type MirrorMessagesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ref string `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` + MessageIds []string `protobuf:"bytes,2,rep,name=message_ids,json=messageIds,proto3" json:"message_ids,omitempty"` + TopicId string `protobuf:"bytes,3,opt,name=topic_id,json=topicId,proto3" json:"topic_id,omitempty"` + // A channel name, resolved for the calling agent; never a channel ID. + Channel string `protobuf:"bytes,4,opt,name=channel,proto3" json:"channel,omitempty"` + Note string `protobuf:"bytes,5,opt,name=note,proto3" json:"note,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MirrorMessagesRequest) Reset() { + *x = MirrorMessagesRequest{} + mi := &file_compass_v1_agent_gateway_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MirrorMessagesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MirrorMessagesRequest) ProtoMessage() {} + +func (x *MirrorMessagesRequest) ProtoReflect() protoreflect.Message { + mi := &file_compass_v1_agent_gateway_proto_msgTypes[13] + 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 MirrorMessagesRequest.ProtoReflect.Descriptor instead. +func (*MirrorMessagesRequest) Descriptor() ([]byte, []int) { + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{13} +} + +func (x *MirrorMessagesRequest) GetRef() string { + if x != nil { + return x.Ref + } + return "" +} + +func (x *MirrorMessagesRequest) GetMessageIds() []string { + if x != nil { + return x.MessageIds + } + return nil +} + +func (x *MirrorMessagesRequest) GetTopicId() string { + if x != nil { + return x.TopicId + } + return "" +} + +func (x *MirrorMessagesRequest) GetChannel() string { + if x != nil { + return x.Channel + } + return "" +} + +func (x *MirrorMessagesRequest) GetNote() string { + if x != nil { + return x.Note + } + return "" +} + // The result of one forge call, correlated by `call_id`. Per DL-069 no raw forge // shape is a wire type: the domain result arms retype to the canonical compass.v1 // types (DL-092 supersedes #995's forge-shaped Issue/PullRequest domain messages), @@ -1612,7 +1702,7 @@ type ForgeCallResult struct { func (x *ForgeCallResult) Reset() { *x = ForgeCallResult{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[13] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1624,7 +1714,7 @@ func (x *ForgeCallResult) String() string { func (*ForgeCallResult) ProtoMessage() {} func (x *ForgeCallResult) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[13] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1637,7 +1727,7 @@ func (x *ForgeCallResult) ProtoReflect() protoreflect.Message { // Deprecated: Use ForgeCallResult.ProtoReflect.Descriptor instead. func (*ForgeCallResult) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{13} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{14} } func (x *ForgeCallResult) GetCallId() string { @@ -1744,7 +1834,7 @@ type ForgeCallResult_Issue struct { } type ForgeCallResult_IssueComment struct { - IssueComment *CommentRef `protobuf:"bytes,3,opt,name=issue_comment,json=issueComment,proto3,oneof"` // comment_on_issue (write ack: url + comment_id) + IssueComment *CommentRef `protobuf:"bytes,3,opt,name=issue_comment,json=issueComment,proto3,oneof"` // comment_on_issue / mirror_messages on an issue } type ForgeCallResult_Issues struct { @@ -1756,7 +1846,7 @@ type ForgeCallResult_PullRequest struct { } type ForgeCallResult_PrComment struct { - PrComment *CommentRef `protobuf:"bytes,6,opt,name=pr_comment,json=prComment,proto3,oneof"` // comment_on_pull_request (write ack) + PrComment *CommentRef `protobuf:"bytes,6,opt,name=pr_comment,json=prComment,proto3,oneof"` // comment_on_pull_request / mirror_messages on a PR } type ForgeCallResult_Subscribed struct { @@ -1807,7 +1897,7 @@ type ForgeCallError struct { func (x *ForgeCallError) Reset() { *x = ForgeCallError{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[14] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1819,7 +1909,7 @@ func (x *ForgeCallError) String() string { func (*ForgeCallError) ProtoMessage() {} func (x *ForgeCallError) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[14] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1832,7 +1922,7 @@ func (x *ForgeCallError) ProtoReflect() protoreflect.Message { // Deprecated: Use ForgeCallError.ProtoReflect.Descriptor instead. func (*ForgeCallError) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{14} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{15} } func (x *ForgeCallError) GetCode() string { @@ -1876,7 +1966,7 @@ type CreateIssueRequest struct { func (x *CreateIssueRequest) Reset() { *x = CreateIssueRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[15] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1888,7 +1978,7 @@ func (x *CreateIssueRequest) String() string { func (*CreateIssueRequest) ProtoMessage() {} func (x *CreateIssueRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[15] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1901,7 +1991,7 @@ func (x *CreateIssueRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateIssueRequest.ProtoReflect.Descriptor instead. func (*CreateIssueRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{15} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{16} } func (x *CreateIssueRequest) GetRepo() string { @@ -1943,7 +2033,7 @@ type CommentOnIssueRequest struct { func (x *CommentOnIssueRequest) Reset() { *x = CommentOnIssueRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[16] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1955,7 +2045,7 @@ func (x *CommentOnIssueRequest) String() string { func (*CommentOnIssueRequest) ProtoMessage() {} func (x *CommentOnIssueRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[16] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1968,7 +2058,7 @@ func (x *CommentOnIssueRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CommentOnIssueRequest.ProtoReflect.Descriptor instead. func (*CommentOnIssueRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{16} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{17} } func (x *CommentOnIssueRequest) GetRepo() string { @@ -2002,7 +2092,7 @@ type GetIssueRequest struct { func (x *GetIssueRequest) Reset() { *x = GetIssueRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[17] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2014,7 +2104,7 @@ func (x *GetIssueRequest) String() string { func (*GetIssueRequest) ProtoMessage() {} func (x *GetIssueRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[17] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2027,7 +2117,7 @@ func (x *GetIssueRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetIssueRequest.ProtoReflect.Descriptor instead. func (*GetIssueRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{17} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{18} } func (x *GetIssueRequest) GetRepo() string { @@ -2056,7 +2146,7 @@ type ListIssuesRequest struct { func (x *ListIssuesRequest) Reset() { *x = ListIssuesRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[18] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2068,7 +2158,7 @@ func (x *ListIssuesRequest) String() string { func (*ListIssuesRequest) ProtoMessage() {} func (x *ListIssuesRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[18] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2081,7 +2171,7 @@ func (x *ListIssuesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListIssuesRequest.ProtoReflect.Descriptor instead. func (*ListIssuesRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{18} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{19} } func (x *ListIssuesRequest) GetRepo() string { @@ -2121,7 +2211,7 @@ type ListIssuesResponse struct { func (x *ListIssuesResponse) Reset() { *x = ListIssuesResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[19] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2133,7 +2223,7 @@ func (x *ListIssuesResponse) String() string { func (*ListIssuesResponse) ProtoMessage() {} func (x *ListIssuesResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[19] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2146,7 +2236,7 @@ func (x *ListIssuesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListIssuesResponse.ProtoReflect.Descriptor instead. func (*ListIssuesResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{19} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{20} } func (x *ListIssuesResponse) GetIssues() []*v1.Issue { @@ -2173,7 +2263,7 @@ type CreatePullRequestRequest struct { func (x *CreatePullRequestRequest) Reset() { *x = CreatePullRequestRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[20] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2185,7 +2275,7 @@ func (x *CreatePullRequestRequest) String() string { func (*CreatePullRequestRequest) ProtoMessage() {} func (x *CreatePullRequestRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[20] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2198,7 +2288,7 @@ func (x *CreatePullRequestRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreatePullRequestRequest.ProtoReflect.Descriptor instead. func (*CreatePullRequestRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{20} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{21} } func (x *CreatePullRequestRequest) GetRepo() string { @@ -2261,7 +2351,7 @@ type PullRequestIssueLink struct { func (x *PullRequestIssueLink) Reset() { *x = PullRequestIssueLink{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[21] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2273,7 +2363,7 @@ func (x *PullRequestIssueLink) String() string { func (*PullRequestIssueLink) ProtoMessage() {} func (x *PullRequestIssueLink) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[21] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2286,7 +2376,7 @@ func (x *PullRequestIssueLink) ProtoReflect() protoreflect.Message { // Deprecated: Use PullRequestIssueLink.ProtoReflect.Descriptor instead. func (*PullRequestIssueLink) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{21} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{22} } func (x *PullRequestIssueLink) GetForge() *v1.ForgeRef { @@ -2321,7 +2411,7 @@ type CommentOnPullRequestRequest struct { func (x *CommentOnPullRequestRequest) Reset() { *x = CommentOnPullRequestRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[22] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2333,7 +2423,7 @@ func (x *CommentOnPullRequestRequest) String() string { func (*CommentOnPullRequestRequest) ProtoMessage() {} func (x *CommentOnPullRequestRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[22] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2346,7 +2436,7 @@ func (x *CommentOnPullRequestRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CommentOnPullRequestRequest.ProtoReflect.Descriptor instead. func (*CommentOnPullRequestRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{22} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{23} } func (x *CommentOnPullRequestRequest) GetRepo() string { @@ -2380,7 +2470,7 @@ type GetPullRequestRequest struct { func (x *GetPullRequestRequest) Reset() { *x = GetPullRequestRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[23] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2392,7 +2482,7 @@ func (x *GetPullRequestRequest) String() string { func (*GetPullRequestRequest) ProtoMessage() {} func (x *GetPullRequestRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[23] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2405,7 +2495,7 @@ func (x *GetPullRequestRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPullRequestRequest.ProtoReflect.Descriptor instead. func (*GetPullRequestRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{23} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{24} } func (x *GetPullRequestRequest) GetRepo() string { @@ -2435,7 +2525,7 @@ type SubmitReviewRequest struct { func (x *SubmitReviewRequest) Reset() { *x = SubmitReviewRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[24] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2447,7 +2537,7 @@ func (x *SubmitReviewRequest) String() string { func (*SubmitReviewRequest) ProtoMessage() {} func (x *SubmitReviewRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[24] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2460,7 +2550,7 @@ func (x *SubmitReviewRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubmitReviewRequest.ProtoReflect.Descriptor instead. func (*SubmitReviewRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{24} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{25} } func (x *SubmitReviewRequest) GetRepo() string { @@ -2510,7 +2600,7 @@ type ReviewCommentInput struct { func (x *ReviewCommentInput) Reset() { *x = ReviewCommentInput{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[25] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2522,7 +2612,7 @@ func (x *ReviewCommentInput) String() string { func (*ReviewCommentInput) ProtoMessage() {} func (x *ReviewCommentInput) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[25] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2535,7 +2625,7 @@ func (x *ReviewCommentInput) ProtoReflect() protoreflect.Message { // Deprecated: Use ReviewCommentInput.ProtoReflect.Descriptor instead. func (*ReviewCommentInput) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{25} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{26} } func (x *ReviewCommentInput) GetPath() string { @@ -2591,7 +2681,7 @@ type TransitionIssueStateRequest struct { func (x *TransitionIssueStateRequest) Reset() { *x = TransitionIssueStateRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[26] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2603,7 +2693,7 @@ func (x *TransitionIssueStateRequest) String() string { func (*TransitionIssueStateRequest) ProtoMessage() {} func (x *TransitionIssueStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[26] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2616,7 +2706,7 @@ func (x *TransitionIssueStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TransitionIssueStateRequest.ProtoReflect.Descriptor instead. func (*TransitionIssueStateRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{26} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{27} } func (x *TransitionIssueStateRequest) GetRepo() string { @@ -2667,7 +2757,7 @@ type TransitionPullRequestStateRequest struct { func (x *TransitionPullRequestStateRequest) Reset() { *x = TransitionPullRequestStateRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[27] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2679,7 +2769,7 @@ func (x *TransitionPullRequestStateRequest) String() string { func (*TransitionPullRequestStateRequest) ProtoMessage() {} func (x *TransitionPullRequestStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[27] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2692,7 +2782,7 @@ func (x *TransitionPullRequestStateRequest) ProtoReflect() protoreflect.Message // Deprecated: Use TransitionPullRequestStateRequest.ProtoReflect.Descriptor instead. func (*TransitionPullRequestStateRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{27} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{28} } func (x *TransitionPullRequestStateRequest) GetRepo() string { @@ -2729,7 +2819,7 @@ type SubscribeForgeRequest struct { func (x *SubscribeForgeRequest) Reset() { *x = SubscribeForgeRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[28] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2741,7 +2831,7 @@ func (x *SubscribeForgeRequest) String() string { func (*SubscribeForgeRequest) ProtoMessage() {} func (x *SubscribeForgeRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[28] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2754,7 +2844,7 @@ func (x *SubscribeForgeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeForgeRequest.ProtoReflect.Descriptor instead. func (*SubscribeForgeRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{28} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{29} } func (x *SubscribeForgeRequest) GetRepo() string { @@ -2801,7 +2891,7 @@ type SubscribeForgeResponse struct { func (x *SubscribeForgeResponse) Reset() { *x = SubscribeForgeResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[29] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2813,7 +2903,7 @@ func (x *SubscribeForgeResponse) String() string { func (*SubscribeForgeResponse) ProtoMessage() {} func (x *SubscribeForgeResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[29] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2826,7 +2916,7 @@ func (x *SubscribeForgeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeForgeResponse.ProtoReflect.Descriptor instead. func (*SubscribeForgeResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{29} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{30} } func (x *SubscribeForgeResponse) GetSubscriptionId() string { @@ -2845,7 +2935,7 @@ type UnsubscribeForgeRequest struct { func (x *UnsubscribeForgeRequest) Reset() { *x = UnsubscribeForgeRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[30] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2857,7 +2947,7 @@ func (x *UnsubscribeForgeRequest) String() string { func (*UnsubscribeForgeRequest) ProtoMessage() {} func (x *UnsubscribeForgeRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[30] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2870,7 +2960,7 @@ func (x *UnsubscribeForgeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnsubscribeForgeRequest.ProtoReflect.Descriptor instead. func (*UnsubscribeForgeRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{30} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{31} } func (x *UnsubscribeForgeRequest) GetSubscriptionId() string { @@ -2888,7 +2978,7 @@ type UnsubscribeForgeResponse struct { func (x *UnsubscribeForgeResponse) Reset() { *x = UnsubscribeForgeResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[31] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2900,7 +2990,7 @@ func (x *UnsubscribeForgeResponse) String() string { func (*UnsubscribeForgeResponse) ProtoMessage() {} func (x *UnsubscribeForgeResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[31] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2913,7 +3003,7 @@ func (x *UnsubscribeForgeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnsubscribeForgeResponse.ProtoReflect.Descriptor instead. func (*UnsubscribeForgeResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{31} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{32} } // One agent-initiated board call. `call_id` is the agent-minted correlation id @@ -2934,7 +3024,7 @@ type BoardCallRequest struct { func (x *BoardCallRequest) Reset() { *x = BoardCallRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[32] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2946,7 +3036,7 @@ func (x *BoardCallRequest) String() string { func (*BoardCallRequest) ProtoMessage() {} func (x *BoardCallRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[32] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2959,7 +3049,7 @@ func (x *BoardCallRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardCallRequest.ProtoReflect.Descriptor instead. func (*BoardCallRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{32} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{33} } func (x *BoardCallRequest) GetCallId() string { @@ -3012,7 +3102,7 @@ type SetIssueStateRequest struct { func (x *SetIssueStateRequest) Reset() { *x = SetIssueStateRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[33] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3024,7 +3114,7 @@ func (x *SetIssueStateRequest) String() string { func (*SetIssueStateRequest) ProtoMessage() {} func (x *SetIssueStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[33] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3037,7 +3127,7 @@ func (x *SetIssueStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetIssueStateRequest.ProtoReflect.Descriptor instead. func (*SetIssueStateRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{33} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{34} } func (x *SetIssueStateRequest) GetIssueId() string { @@ -3064,7 +3154,7 @@ type SetIssueStateResponse struct { func (x *SetIssueStateResponse) Reset() { *x = SetIssueStateResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[34] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3076,7 +3166,7 @@ func (x *SetIssueStateResponse) String() string { func (*SetIssueStateResponse) ProtoMessage() {} func (x *SetIssueStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[34] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3089,7 +3179,7 @@ func (x *SetIssueStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetIssueStateResponse.ProtoReflect.Descriptor instead. func (*SetIssueStateResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{34} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{35} } func (x *SetIssueStateResponse) GetIssue() *v1.Issue { @@ -3117,7 +3207,7 @@ type BoardCallResult struct { func (x *BoardCallResult) Reset() { *x = BoardCallResult{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[35] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3129,7 +3219,7 @@ func (x *BoardCallResult) String() string { func (*BoardCallResult) ProtoMessage() {} func (x *BoardCallResult) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[35] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3142,7 +3232,7 @@ func (x *BoardCallResult) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardCallResult.ProtoReflect.Descriptor instead. func (*BoardCallResult) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{35} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{36} } func (x *BoardCallResult) GetCallId() string { @@ -3206,7 +3296,7 @@ type BoardCallError struct { func (x *BoardCallError) Reset() { *x = BoardCallError{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[36] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3218,7 +3308,7 @@ func (x *BoardCallError) String() string { func (*BoardCallError) ProtoMessage() {} func (x *BoardCallError) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[36] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3231,7 +3321,7 @@ func (x *BoardCallError) ProtoReflect() protoreflect.Message { // Deprecated: Use BoardCallError.ProtoReflect.Descriptor instead. func (*BoardCallError) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{36} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{37} } func (x *BoardCallError) GetCode() string { @@ -3261,7 +3351,7 @@ type PublishFrameRequest struct { func (x *PublishFrameRequest) Reset() { *x = PublishFrameRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[37] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3273,7 +3363,7 @@ func (x *PublishFrameRequest) String() string { func (*PublishFrameRequest) ProtoMessage() {} func (x *PublishFrameRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[37] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3286,7 +3376,7 @@ func (x *PublishFrameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PublishFrameRequest.ProtoReflect.Descriptor instead. func (*PublishFrameRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{37} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{38} } func (x *PublishFrameRequest) GetFrame() *AgentFrame { @@ -3305,7 +3395,7 @@ type PublishFrameResponse struct { func (x *PublishFrameResponse) Reset() { *x = PublishFrameResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[38] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3317,7 +3407,7 @@ func (x *PublishFrameResponse) String() string { func (*PublishFrameResponse) ProtoMessage() {} func (x *PublishFrameResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[38] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3330,7 +3420,7 @@ func (x *PublishFrameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PublishFrameResponse.ProtoReflect.Descriptor instead. func (*PublishFrameResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{38} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{39} } // The durable-frame unary carries the SAME AgentFrame message, constrained by @@ -3352,7 +3442,7 @@ type PostConversationFrameRequest struct { func (x *PostConversationFrameRequest) Reset() { *x = PostConversationFrameRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[39] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3364,7 +3454,7 @@ func (x *PostConversationFrameRequest) String() string { func (*PostConversationFrameRequest) ProtoMessage() {} func (x *PostConversationFrameRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[39] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3377,7 +3467,7 @@ func (x *PostConversationFrameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PostConversationFrameRequest.ProtoReflect.Descriptor instead. func (*PostConversationFrameRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{39} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{40} } func (x *PostConversationFrameRequest) GetFrame() *AgentFrame { @@ -3403,7 +3493,7 @@ type PostConversationFrameResponse struct { func (x *PostConversationFrameResponse) Reset() { *x = PostConversationFrameResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[40] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3415,7 +3505,7 @@ func (x *PostConversationFrameResponse) String() string { func (*PostConversationFrameResponse) ProtoMessage() {} func (x *PostConversationFrameResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[40] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3428,7 +3518,7 @@ func (x *PostConversationFrameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PostConversationFrameResponse.ProtoReflect.Descriptor instead. func (*PostConversationFrameResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{40} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{41} } // The Control subscribe request carries no session id: the per-container socket @@ -3441,7 +3531,7 @@ type ControlSubscribeRequest struct { func (x *ControlSubscribeRequest) Reset() { *x = ControlSubscribeRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[41] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3453,7 +3543,7 @@ func (x *ControlSubscribeRequest) String() string { func (*ControlSubscribeRequest) ProtoMessage() {} func (x *ControlSubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[41] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3466,7 +3556,7 @@ func (x *ControlSubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlSubscribeRequest.ProtoReflect.Descriptor instead. func (*ControlSubscribeRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{41} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{42} } // One content-addressed session blob. sha256 is lowercase hex of data; the @@ -3481,7 +3571,7 @@ type PutSessionBlobRequest struct { func (x *PutSessionBlobRequest) Reset() { *x = PutSessionBlobRequest{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[42] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3493,7 +3583,7 @@ func (x *PutSessionBlobRequest) String() string { func (*PutSessionBlobRequest) ProtoMessage() {} func (x *PutSessionBlobRequest) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[42] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3506,7 +3596,7 @@ func (x *PutSessionBlobRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PutSessionBlobRequest.ProtoReflect.Descriptor instead. func (*PutSessionBlobRequest) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{42} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{43} } func (x *PutSessionBlobRequest) GetSha256() string { @@ -3531,7 +3621,7 @@ type PutSessionBlobResponse struct { func (x *PutSessionBlobResponse) Reset() { *x = PutSessionBlobResponse{} - mi := &file_compass_v1_agent_gateway_proto_msgTypes[43] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3543,7 +3633,7 @@ func (x *PutSessionBlobResponse) String() string { func (*PutSessionBlobResponse) ProtoMessage() {} func (x *PutSessionBlobResponse) ProtoReflect() protoreflect.Message { - mi := &file_compass_v1_agent_gateway_proto_msgTypes[43] + mi := &file_compass_v1_agent_gateway_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3556,7 +3646,7 @@ func (x *PutSessionBlobResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PutSessionBlobResponse.ProtoReflect.Descriptor instead. func (*PutSessionBlobResponse) Descriptor() ([]byte, []int) { - return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{43} + return file_compass_v1_agent_gateway_proto_rawDescGZIP(), []int{44} } var File_compass_v1_agent_gateway_proto protoreflect.FileDescriptor @@ -3637,7 +3727,7 @@ const file_compass_v1_agent_gateway_proto_rawDesc = "" + "\x06result\"B\n" + "\x12LifecycleCallError\x12\x12\n" + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\xcf\b\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"\x9d\t\n" + "\x10ForgeCallRequest\x12\x17\n" + "\acall_id\x18\x01 \x01(\tR\x06callId\x12C\n" + "\fcreate_issue\x18\x02 \x01(\v2\x1e.compass.v1.CreateIssueRequestH\x00R\vcreateIssue\x12M\n" + @@ -3651,12 +3741,20 @@ const file_compass_v1_agent_gateway_proto_rawDesc = "" + "\tsubscribe\x18\t \x01(\v2!.compass.v1.SubscribeForgeRequestH\x00R\tsubscribe\x12G\n" + "\vunsubscribe\x18\n" + " \x01(\v2#.compass.v1.UnsubscribeForgeRequestH\x00R\vunsubscribe\x12F\n" + - "\rsubmit_review\x18\v \x01(\v2\x1f.compass.v1.SubmitReviewRequestH\x00R\fsubmitReview\x12_\n" + + "\rsubmit_review\x18\v \x01(\v2\x1f.compass.v1.SubmitReviewRequestH\x00R\fsubmitReview\x12L\n" + + "\x0fmirror_messages\x18\x10 \x01(\v2!.compass.v1.MirrorMessagesRequestH\x00R\x0emirrorMessages\x12_\n" + "\x16transition_issue_state\x18\x0e \x01(\v2'.compass.v1.TransitionIssueStateRequestH\x00R\x14transitionIssueState\x12r\n" + "\x1dtransition_pull_request_state\x18\x0f \x01(\v2-.compass.v1.TransitionPullRequestStateRequestH\x00R\x1atransitionPullRequestState\x12*\n" + "\x05forge\x18\f \x01(\v2\x14.compass.v1.ForgeRefR\x05forge\x12*\n" + "\x11client_request_id\x18\r \x01(\tR\x0fclientRequestIdB\x06\n" + - "\x04call\"\xc6\x04\n" + + "\x04call\"\x93\x01\n" + + "\x15MirrorMessagesRequest\x12\x10\n" + + "\x03ref\x18\x01 \x01(\tR\x03ref\x12\x1f\n" + + "\vmessage_ids\x18\x02 \x03(\tR\n" + + "messageIds\x12\x19\n" + + "\btopic_id\x18\x03 \x01(\tR\atopicId\x12\x18\n" + + "\achannel\x18\x04 \x01(\tR\achannel\x12\x12\n" + + "\x04note\x18\x05 \x01(\tR\x04note\"\xc6\x04\n" + "\x0fForgeCallResult\x12\x17\n" + "\acall_id\x18\x01 \x01(\tR\x06callId\x12)\n" + "\x05issue\x18\x02 \x01(\v2\x11.compass.v1.IssueH\x00R\x05issue\x12=\n" + @@ -3803,7 +3901,7 @@ func file_compass_v1_agent_gateway_proto_rawDescGZIP() []byte { } var file_compass_v1_agent_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_compass_v1_agent_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 44) +var file_compass_v1_agent_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 45) var file_compass_v1_agent_gateway_proto_goTypes = []any{ (ForgeSubscriptionScope)(0), // 0: compass.v1.ForgeSubscriptionScope (*CommsCallRequest)(nil), // 1: compass.v1.CommsCallRequest @@ -3819,156 +3917,158 @@ var file_compass_v1_agent_gateway_proto_goTypes = []any{ (*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 - (*PullRequestIssueLink)(nil), // 22: compass.v1.PullRequestIssueLink - (*CommentOnPullRequestRequest)(nil), // 23: compass.v1.CommentOnPullRequestRequest - (*GetPullRequestRequest)(nil), // 24: compass.v1.GetPullRequestRequest - (*SubmitReviewRequest)(nil), // 25: compass.v1.SubmitReviewRequest - (*ReviewCommentInput)(nil), // 26: compass.v1.ReviewCommentInput - (*TransitionIssueStateRequest)(nil), // 27: compass.v1.TransitionIssueStateRequest - (*TransitionPullRequestStateRequest)(nil), // 28: compass.v1.TransitionPullRequestStateRequest - (*SubscribeForgeRequest)(nil), // 29: compass.v1.SubscribeForgeRequest - (*SubscribeForgeResponse)(nil), // 30: compass.v1.SubscribeForgeResponse - (*UnsubscribeForgeRequest)(nil), // 31: compass.v1.UnsubscribeForgeRequest - (*UnsubscribeForgeResponse)(nil), // 32: compass.v1.UnsubscribeForgeResponse - (*BoardCallRequest)(nil), // 33: compass.v1.BoardCallRequest - (*SetIssueStateRequest)(nil), // 34: compass.v1.SetIssueStateRequest - (*SetIssueStateResponse)(nil), // 35: compass.v1.SetIssueStateResponse - (*BoardCallResult)(nil), // 36: compass.v1.BoardCallResult - (*BoardCallError)(nil), // 37: compass.v1.BoardCallError - (*PublishFrameRequest)(nil), // 38: compass.v1.PublishFrameRequest - (*PublishFrameResponse)(nil), // 39: compass.v1.PublishFrameResponse - (*PostConversationFrameRequest)(nil), // 40: compass.v1.PostConversationFrameRequest - (*PostConversationFrameResponse)(nil), // 41: compass.v1.PostConversationFrameResponse - (*ControlSubscribeRequest)(nil), // 42: compass.v1.ControlSubscribeRequest - (*PutSessionBlobRequest)(nil), // 43: compass.v1.PutSessionBlobRequest - (*PutSessionBlobResponse)(nil), // 44: compass.v1.PutSessionBlobResponse - (*v1.PostMessageRequest)(nil), // 45: compass.v1.PostMessageRequest - (*v1.ListMessagesRequest)(nil), // 46: compass.v1.ListMessagesRequest - (*v1.GetRosterRequest)(nil), // 47: compass.v1.GetRosterRequest - (*v1.UpdatePinnedBoardRequest)(nil), // 48: compass.v1.UpdatePinnedBoardRequest - (*v1.CreateChannelRequest)(nil), // 49: compass.v1.CreateChannelRequest - (*v1.UpdateChannelMembersRequest)(nil), // 50: compass.v1.UpdateChannelMembersRequest - (*v1.CreateChannelGroupRequest)(nil), // 51: compass.v1.CreateChannelGroupRequest - (*v1.OpenDMRequest)(nil), // 52: compass.v1.OpenDMRequest - (*v1.ListTopicsRequest)(nil), // 53: compass.v1.ListTopicsRequest - (*v1.UpdateAgentBoardRequest)(nil), // 54: compass.v1.UpdateAgentBoardRequest - (*v1.ListAgentBoardsRequest)(nil), // 55: compass.v1.ListAgentBoardsRequest - (*v1.PostMessageResponse)(nil), // 56: compass.v1.PostMessageResponse - (*v1.ListMessagesResponse)(nil), // 57: compass.v1.ListMessagesResponse - (*v1.GetRosterResponse)(nil), // 58: compass.v1.GetRosterResponse - (*v1.UpdatePinnedBoardResponse)(nil), // 59: compass.v1.UpdatePinnedBoardResponse - (*v1.CreateChannelResponse)(nil), // 60: compass.v1.CreateChannelResponse - (*v1.UpdateChannelMembersResponse)(nil), // 61: compass.v1.UpdateChannelMembersResponse - (*v1.CreateChannelGroupResponse)(nil), // 62: compass.v1.CreateChannelGroupResponse - (*v1.OpenDMResponse)(nil), // 63: compass.v1.OpenDMResponse - (*v1.ListTopicsResponse)(nil), // 64: compass.v1.ListTopicsResponse - (*v1.UpdateAgentBoardResponse)(nil), // 65: compass.v1.UpdateAgentBoardResponse - (*v1.ListAgentBoardsResponse)(nil), // 66: compass.v1.ListAgentBoardsResponse - (*v1.ForgeRef)(nil), // 67: compass.v1.ForgeRef - (*v1.Issue)(nil), // 68: compass.v1.Issue - (*CommentRef)(nil), // 69: compass.v1.CommentRef - (*v1.PullRequest)(nil), // 70: compass.v1.PullRequest - (*ReviewRef)(nil), // 71: compass.v1.ReviewRef - (ForgeArtifactKind)(0), // 72: compass.v1.ForgeArtifactKind - (v1.IssueState)(0), // 73: compass.v1.IssueState - (*AgentFrame)(nil), // 74: compass.v1.AgentFrame - (*AgentControl)(nil), // 75: compass.v1.AgentControl + (*MirrorMessagesRequest)(nil), // 14: compass.v1.MirrorMessagesRequest + (*ForgeCallResult)(nil), // 15: compass.v1.ForgeCallResult + (*ForgeCallError)(nil), // 16: compass.v1.ForgeCallError + (*CreateIssueRequest)(nil), // 17: compass.v1.CreateIssueRequest + (*CommentOnIssueRequest)(nil), // 18: compass.v1.CommentOnIssueRequest + (*GetIssueRequest)(nil), // 19: compass.v1.GetIssueRequest + (*ListIssuesRequest)(nil), // 20: compass.v1.ListIssuesRequest + (*ListIssuesResponse)(nil), // 21: compass.v1.ListIssuesResponse + (*CreatePullRequestRequest)(nil), // 22: compass.v1.CreatePullRequestRequest + (*PullRequestIssueLink)(nil), // 23: compass.v1.PullRequestIssueLink + (*CommentOnPullRequestRequest)(nil), // 24: compass.v1.CommentOnPullRequestRequest + (*GetPullRequestRequest)(nil), // 25: compass.v1.GetPullRequestRequest + (*SubmitReviewRequest)(nil), // 26: compass.v1.SubmitReviewRequest + (*ReviewCommentInput)(nil), // 27: compass.v1.ReviewCommentInput + (*TransitionIssueStateRequest)(nil), // 28: compass.v1.TransitionIssueStateRequest + (*TransitionPullRequestStateRequest)(nil), // 29: compass.v1.TransitionPullRequestStateRequest + (*SubscribeForgeRequest)(nil), // 30: compass.v1.SubscribeForgeRequest + (*SubscribeForgeResponse)(nil), // 31: compass.v1.SubscribeForgeResponse + (*UnsubscribeForgeRequest)(nil), // 32: compass.v1.UnsubscribeForgeRequest + (*UnsubscribeForgeResponse)(nil), // 33: compass.v1.UnsubscribeForgeResponse + (*BoardCallRequest)(nil), // 34: compass.v1.BoardCallRequest + (*SetIssueStateRequest)(nil), // 35: compass.v1.SetIssueStateRequest + (*SetIssueStateResponse)(nil), // 36: compass.v1.SetIssueStateResponse + (*BoardCallResult)(nil), // 37: compass.v1.BoardCallResult + (*BoardCallError)(nil), // 38: compass.v1.BoardCallError + (*PublishFrameRequest)(nil), // 39: compass.v1.PublishFrameRequest + (*PublishFrameResponse)(nil), // 40: compass.v1.PublishFrameResponse + (*PostConversationFrameRequest)(nil), // 41: compass.v1.PostConversationFrameRequest + (*PostConversationFrameResponse)(nil), // 42: compass.v1.PostConversationFrameResponse + (*ControlSubscribeRequest)(nil), // 43: compass.v1.ControlSubscribeRequest + (*PutSessionBlobRequest)(nil), // 44: compass.v1.PutSessionBlobRequest + (*PutSessionBlobResponse)(nil), // 45: compass.v1.PutSessionBlobResponse + (*v1.PostMessageRequest)(nil), // 46: compass.v1.PostMessageRequest + (*v1.ListMessagesRequest)(nil), // 47: compass.v1.ListMessagesRequest + (*v1.GetRosterRequest)(nil), // 48: compass.v1.GetRosterRequest + (*v1.UpdatePinnedBoardRequest)(nil), // 49: compass.v1.UpdatePinnedBoardRequest + (*v1.CreateChannelRequest)(nil), // 50: compass.v1.CreateChannelRequest + (*v1.UpdateChannelMembersRequest)(nil), // 51: compass.v1.UpdateChannelMembersRequest + (*v1.CreateChannelGroupRequest)(nil), // 52: compass.v1.CreateChannelGroupRequest + (*v1.OpenDMRequest)(nil), // 53: compass.v1.OpenDMRequest + (*v1.ListTopicsRequest)(nil), // 54: compass.v1.ListTopicsRequest + (*v1.UpdateAgentBoardRequest)(nil), // 55: compass.v1.UpdateAgentBoardRequest + (*v1.ListAgentBoardsRequest)(nil), // 56: compass.v1.ListAgentBoardsRequest + (*v1.PostMessageResponse)(nil), // 57: compass.v1.PostMessageResponse + (*v1.ListMessagesResponse)(nil), // 58: compass.v1.ListMessagesResponse + (*v1.GetRosterResponse)(nil), // 59: compass.v1.GetRosterResponse + (*v1.UpdatePinnedBoardResponse)(nil), // 60: compass.v1.UpdatePinnedBoardResponse + (*v1.CreateChannelResponse)(nil), // 61: compass.v1.CreateChannelResponse + (*v1.UpdateChannelMembersResponse)(nil), // 62: compass.v1.UpdateChannelMembersResponse + (*v1.CreateChannelGroupResponse)(nil), // 63: compass.v1.CreateChannelGroupResponse + (*v1.OpenDMResponse)(nil), // 64: compass.v1.OpenDMResponse + (*v1.ListTopicsResponse)(nil), // 65: compass.v1.ListTopicsResponse + (*v1.UpdateAgentBoardResponse)(nil), // 66: compass.v1.UpdateAgentBoardResponse + (*v1.ListAgentBoardsResponse)(nil), // 67: compass.v1.ListAgentBoardsResponse + (*v1.ForgeRef)(nil), // 68: compass.v1.ForgeRef + (*v1.Issue)(nil), // 69: compass.v1.Issue + (*CommentRef)(nil), // 70: compass.v1.CommentRef + (*v1.PullRequest)(nil), // 71: compass.v1.PullRequest + (*ReviewRef)(nil), // 72: compass.v1.ReviewRef + (ForgeArtifactKind)(0), // 73: compass.v1.ForgeArtifactKind + (v1.IssueState)(0), // 74: compass.v1.IssueState + (*AgentFrame)(nil), // 75: compass.v1.AgentFrame + (*AgentControl)(nil), // 76: compass.v1.AgentControl } var file_compass_v1_agent_gateway_proto_depIdxs = []int32{ - 45, // 0: compass.v1.CommsCallRequest.post:type_name -> compass.v1.PostMessageRequest - 46, // 1: compass.v1.CommsCallRequest.list:type_name -> compass.v1.ListMessagesRequest - 47, // 2: compass.v1.CommsCallRequest.roster:type_name -> compass.v1.GetRosterRequest + 46, // 0: compass.v1.CommsCallRequest.post:type_name -> compass.v1.PostMessageRequest + 47, // 1: compass.v1.CommsCallRequest.list:type_name -> compass.v1.ListMessagesRequest + 48, // 2: compass.v1.CommsCallRequest.roster:type_name -> compass.v1.GetRosterRequest 4, // 3: compass.v1.CommsCallRequest.set_status:type_name -> compass.v1.SetAgentStatusRequest - 48, // 4: compass.v1.CommsCallRequest.pin:type_name -> compass.v1.UpdatePinnedBoardRequest - 49, // 5: compass.v1.CommsCallRequest.create_channel:type_name -> compass.v1.CreateChannelRequest - 50, // 6: compass.v1.CommsCallRequest.update_members:type_name -> compass.v1.UpdateChannelMembersRequest - 51, // 7: compass.v1.CommsCallRequest.create_channel_group:type_name -> compass.v1.CreateChannelGroupRequest - 52, // 8: compass.v1.CommsCallRequest.open_dm:type_name -> compass.v1.OpenDMRequest - 53, // 9: compass.v1.CommsCallRequest.list_topics:type_name -> compass.v1.ListTopicsRequest - 54, // 10: compass.v1.CommsCallRequest.update_board:type_name -> compass.v1.UpdateAgentBoardRequest - 55, // 11: compass.v1.CommsCallRequest.boards:type_name -> compass.v1.ListAgentBoardsRequest - 56, // 12: compass.v1.CommsCallResult.post:type_name -> compass.v1.PostMessageResponse - 57, // 13: compass.v1.CommsCallResult.list:type_name -> compass.v1.ListMessagesResponse + 49, // 4: compass.v1.CommsCallRequest.pin:type_name -> compass.v1.UpdatePinnedBoardRequest + 50, // 5: compass.v1.CommsCallRequest.create_channel:type_name -> compass.v1.CreateChannelRequest + 51, // 6: compass.v1.CommsCallRequest.update_members:type_name -> compass.v1.UpdateChannelMembersRequest + 52, // 7: compass.v1.CommsCallRequest.create_channel_group:type_name -> compass.v1.CreateChannelGroupRequest + 53, // 8: compass.v1.CommsCallRequest.open_dm:type_name -> compass.v1.OpenDMRequest + 54, // 9: compass.v1.CommsCallRequest.list_topics:type_name -> compass.v1.ListTopicsRequest + 55, // 10: compass.v1.CommsCallRequest.update_board:type_name -> compass.v1.UpdateAgentBoardRequest + 56, // 11: compass.v1.CommsCallRequest.boards:type_name -> compass.v1.ListAgentBoardsRequest + 57, // 12: compass.v1.CommsCallResult.post:type_name -> compass.v1.PostMessageResponse + 58, // 13: compass.v1.CommsCallResult.list:type_name -> compass.v1.ListMessagesResponse 3, // 14: compass.v1.CommsCallResult.error:type_name -> compass.v1.CommsCallError - 58, // 15: compass.v1.CommsCallResult.roster:type_name -> compass.v1.GetRosterResponse + 59, // 15: compass.v1.CommsCallResult.roster:type_name -> compass.v1.GetRosterResponse 5, // 16: compass.v1.CommsCallResult.set_status:type_name -> compass.v1.SetAgentStatusResponse - 59, // 17: compass.v1.CommsCallResult.pin:type_name -> compass.v1.UpdatePinnedBoardResponse - 60, // 18: compass.v1.CommsCallResult.create_channel:type_name -> compass.v1.CreateChannelResponse - 61, // 19: compass.v1.CommsCallResult.update_members:type_name -> compass.v1.UpdateChannelMembersResponse - 62, // 20: compass.v1.CommsCallResult.create_channel_group:type_name -> compass.v1.CreateChannelGroupResponse - 63, // 21: compass.v1.CommsCallResult.open_dm:type_name -> compass.v1.OpenDMResponse - 64, // 22: compass.v1.CommsCallResult.list_topics:type_name -> compass.v1.ListTopicsResponse - 65, // 23: compass.v1.CommsCallResult.update_board:type_name -> compass.v1.UpdateAgentBoardResponse - 66, // 24: compass.v1.CommsCallResult.boards:type_name -> compass.v1.ListAgentBoardsResponse + 60, // 17: compass.v1.CommsCallResult.pin:type_name -> compass.v1.UpdatePinnedBoardResponse + 61, // 18: compass.v1.CommsCallResult.create_channel:type_name -> compass.v1.CreateChannelResponse + 62, // 19: compass.v1.CommsCallResult.update_members:type_name -> compass.v1.UpdateChannelMembersResponse + 63, // 20: compass.v1.CommsCallResult.create_channel_group:type_name -> compass.v1.CreateChannelGroupResponse + 64, // 21: compass.v1.CommsCallResult.open_dm:type_name -> compass.v1.OpenDMResponse + 65, // 22: compass.v1.CommsCallResult.list_topics:type_name -> compass.v1.ListTopicsResponse + 66, // 23: compass.v1.CommsCallResult.update_board:type_name -> compass.v1.UpdateAgentBoardResponse + 67, // 24: compass.v1.CommsCallResult.boards:type_name -> compass.v1.ListAgentBoardsResponse 7, // 25: compass.v1.LifecycleCallRequest.spawn:type_name -> compass.v1.SpawnPeerRequest 9, // 26: compass.v1.LifecycleCallRequest.despawn:type_name -> compass.v1.DespawnPeerRequest 8, // 27: compass.v1.LifecycleCallResult.spawn:type_name -> compass.v1.SpawnPeerResponse 10, // 28: compass.v1.LifecycleCallResult.despawn:type_name -> compass.v1.DespawnPeerResponse 12, // 29: compass.v1.LifecycleCallResult.error:type_name -> compass.v1.LifecycleCallError - 16, // 30: compass.v1.ForgeCallRequest.create_issue:type_name -> compass.v1.CreateIssueRequest - 17, // 31: compass.v1.ForgeCallRequest.comment_on_issue:type_name -> compass.v1.CommentOnIssueRequest - 18, // 32: compass.v1.ForgeCallRequest.get_issue:type_name -> compass.v1.GetIssueRequest - 19, // 33: compass.v1.ForgeCallRequest.list_issues:type_name -> compass.v1.ListIssuesRequest - 21, // 34: compass.v1.ForgeCallRequest.create_pull_request:type_name -> compass.v1.CreatePullRequestRequest - 23, // 35: compass.v1.ForgeCallRequest.comment_on_pull_request:type_name -> compass.v1.CommentOnPullRequestRequest - 24, // 36: compass.v1.ForgeCallRequest.get_pull_request:type_name -> compass.v1.GetPullRequestRequest - 29, // 37: compass.v1.ForgeCallRequest.subscribe:type_name -> compass.v1.SubscribeForgeRequest - 31, // 38: compass.v1.ForgeCallRequest.unsubscribe:type_name -> compass.v1.UnsubscribeForgeRequest - 25, // 39: compass.v1.ForgeCallRequest.submit_review:type_name -> compass.v1.SubmitReviewRequest - 27, // 40: compass.v1.ForgeCallRequest.transition_issue_state:type_name -> compass.v1.TransitionIssueStateRequest - 28, // 41: compass.v1.ForgeCallRequest.transition_pull_request_state:type_name -> compass.v1.TransitionPullRequestStateRequest - 67, // 42: compass.v1.ForgeCallRequest.forge:type_name -> compass.v1.ForgeRef - 68, // 43: compass.v1.ForgeCallResult.issue:type_name -> compass.v1.Issue - 69, // 44: compass.v1.ForgeCallResult.issue_comment:type_name -> compass.v1.CommentRef - 20, // 45: compass.v1.ForgeCallResult.issues:type_name -> compass.v1.ListIssuesResponse - 70, // 46: compass.v1.ForgeCallResult.pull_request:type_name -> compass.v1.PullRequest - 69, // 47: compass.v1.ForgeCallResult.pr_comment:type_name -> compass.v1.CommentRef - 30, // 48: compass.v1.ForgeCallResult.subscribed:type_name -> compass.v1.SubscribeForgeResponse - 32, // 49: compass.v1.ForgeCallResult.unsubscribed:type_name -> compass.v1.UnsubscribeForgeResponse - 15, // 50: compass.v1.ForgeCallResult.error:type_name -> compass.v1.ForgeCallError - 71, // 51: compass.v1.ForgeCallResult.review:type_name -> compass.v1.ReviewRef - 68, // 52: compass.v1.ListIssuesResponse.issues:type_name -> compass.v1.Issue - 22, // 53: compass.v1.CreatePullRequestRequest.issue:type_name -> compass.v1.PullRequestIssueLink - 67, // 54: compass.v1.PullRequestIssueLink.forge:type_name -> compass.v1.ForgeRef - 26, // 55: compass.v1.SubmitReviewRequest.comments:type_name -> compass.v1.ReviewCommentInput - 72, // 56: compass.v1.SubscribeForgeRequest.kind:type_name -> compass.v1.ForgeArtifactKind - 0, // 57: compass.v1.SubscribeForgeRequest.scope:type_name -> compass.v1.ForgeSubscriptionScope - 34, // 58: compass.v1.BoardCallRequest.set_issue_state:type_name -> compass.v1.SetIssueStateRequest - 73, // 59: compass.v1.SetIssueStateRequest.state:type_name -> compass.v1.IssueState - 68, // 60: compass.v1.SetIssueStateResponse.issue:type_name -> compass.v1.Issue - 35, // 61: compass.v1.BoardCallResult.set_issue_state:type_name -> compass.v1.SetIssueStateResponse - 37, // 62: compass.v1.BoardCallResult.error:type_name -> compass.v1.BoardCallError - 74, // 63: compass.v1.PublishFrameRequest.frame:type_name -> compass.v1.AgentFrame - 74, // 64: compass.v1.PostConversationFrameRequest.frame:type_name -> compass.v1.AgentFrame - 1, // 65: compass.v1.AgentGateway.Comms:input_type -> compass.v1.CommsCallRequest - 6, // 66: compass.v1.AgentGateway.Lifecycle:input_type -> compass.v1.LifecycleCallRequest - 38, // 67: compass.v1.AgentGateway.Publish:input_type -> compass.v1.PublishFrameRequest - 40, // 68: compass.v1.AgentGateway.PostConversationFrame:input_type -> compass.v1.PostConversationFrameRequest - 42, // 69: compass.v1.AgentGateway.Control:input_type -> compass.v1.ControlSubscribeRequest - 13, // 70: compass.v1.AgentGateway.Forge:input_type -> compass.v1.ForgeCallRequest - 33, // 71: compass.v1.AgentGateway.Board:input_type -> compass.v1.BoardCallRequest - 43, // 72: compass.v1.AgentGateway.PutSessionBlob:input_type -> compass.v1.PutSessionBlobRequest - 2, // 73: compass.v1.AgentGateway.Comms:output_type -> compass.v1.CommsCallResult - 11, // 74: compass.v1.AgentGateway.Lifecycle:output_type -> compass.v1.LifecycleCallResult - 39, // 75: compass.v1.AgentGateway.Publish:output_type -> compass.v1.PublishFrameResponse - 41, // 76: compass.v1.AgentGateway.PostConversationFrame:output_type -> compass.v1.PostConversationFrameResponse - 75, // 77: compass.v1.AgentGateway.Control:output_type -> compass.v1.AgentControl - 14, // 78: compass.v1.AgentGateway.Forge:output_type -> compass.v1.ForgeCallResult - 36, // 79: compass.v1.AgentGateway.Board:output_type -> compass.v1.BoardCallResult - 44, // 80: compass.v1.AgentGateway.PutSessionBlob:output_type -> compass.v1.PutSessionBlobResponse - 73, // [73:81] is the sub-list for method output_type - 65, // [65:73] is the sub-list for method input_type - 65, // [65:65] is the sub-list for extension type_name - 65, // [65:65] is the sub-list for extension extendee - 0, // [0:65] is the sub-list for field type_name + 17, // 30: compass.v1.ForgeCallRequest.create_issue:type_name -> compass.v1.CreateIssueRequest + 18, // 31: compass.v1.ForgeCallRequest.comment_on_issue:type_name -> compass.v1.CommentOnIssueRequest + 19, // 32: compass.v1.ForgeCallRequest.get_issue:type_name -> compass.v1.GetIssueRequest + 20, // 33: compass.v1.ForgeCallRequest.list_issues:type_name -> compass.v1.ListIssuesRequest + 22, // 34: compass.v1.ForgeCallRequest.create_pull_request:type_name -> compass.v1.CreatePullRequestRequest + 24, // 35: compass.v1.ForgeCallRequest.comment_on_pull_request:type_name -> compass.v1.CommentOnPullRequestRequest + 25, // 36: compass.v1.ForgeCallRequest.get_pull_request:type_name -> compass.v1.GetPullRequestRequest + 30, // 37: compass.v1.ForgeCallRequest.subscribe:type_name -> compass.v1.SubscribeForgeRequest + 32, // 38: compass.v1.ForgeCallRequest.unsubscribe:type_name -> compass.v1.UnsubscribeForgeRequest + 26, // 39: compass.v1.ForgeCallRequest.submit_review:type_name -> compass.v1.SubmitReviewRequest + 14, // 40: compass.v1.ForgeCallRequest.mirror_messages:type_name -> compass.v1.MirrorMessagesRequest + 28, // 41: compass.v1.ForgeCallRequest.transition_issue_state:type_name -> compass.v1.TransitionIssueStateRequest + 29, // 42: compass.v1.ForgeCallRequest.transition_pull_request_state:type_name -> compass.v1.TransitionPullRequestStateRequest + 68, // 43: compass.v1.ForgeCallRequest.forge:type_name -> compass.v1.ForgeRef + 69, // 44: compass.v1.ForgeCallResult.issue:type_name -> compass.v1.Issue + 70, // 45: compass.v1.ForgeCallResult.issue_comment:type_name -> compass.v1.CommentRef + 21, // 46: compass.v1.ForgeCallResult.issues:type_name -> compass.v1.ListIssuesResponse + 71, // 47: compass.v1.ForgeCallResult.pull_request:type_name -> compass.v1.PullRequest + 70, // 48: compass.v1.ForgeCallResult.pr_comment:type_name -> compass.v1.CommentRef + 31, // 49: compass.v1.ForgeCallResult.subscribed:type_name -> compass.v1.SubscribeForgeResponse + 33, // 50: compass.v1.ForgeCallResult.unsubscribed:type_name -> compass.v1.UnsubscribeForgeResponse + 16, // 51: compass.v1.ForgeCallResult.error:type_name -> compass.v1.ForgeCallError + 72, // 52: compass.v1.ForgeCallResult.review:type_name -> compass.v1.ReviewRef + 69, // 53: compass.v1.ListIssuesResponse.issues:type_name -> compass.v1.Issue + 23, // 54: compass.v1.CreatePullRequestRequest.issue:type_name -> compass.v1.PullRequestIssueLink + 68, // 55: compass.v1.PullRequestIssueLink.forge:type_name -> compass.v1.ForgeRef + 27, // 56: compass.v1.SubmitReviewRequest.comments:type_name -> compass.v1.ReviewCommentInput + 73, // 57: compass.v1.SubscribeForgeRequest.kind:type_name -> compass.v1.ForgeArtifactKind + 0, // 58: compass.v1.SubscribeForgeRequest.scope:type_name -> compass.v1.ForgeSubscriptionScope + 35, // 59: compass.v1.BoardCallRequest.set_issue_state:type_name -> compass.v1.SetIssueStateRequest + 74, // 60: compass.v1.SetIssueStateRequest.state:type_name -> compass.v1.IssueState + 69, // 61: compass.v1.SetIssueStateResponse.issue:type_name -> compass.v1.Issue + 36, // 62: compass.v1.BoardCallResult.set_issue_state:type_name -> compass.v1.SetIssueStateResponse + 38, // 63: compass.v1.BoardCallResult.error:type_name -> compass.v1.BoardCallError + 75, // 64: compass.v1.PublishFrameRequest.frame:type_name -> compass.v1.AgentFrame + 75, // 65: compass.v1.PostConversationFrameRequest.frame:type_name -> compass.v1.AgentFrame + 1, // 66: compass.v1.AgentGateway.Comms:input_type -> compass.v1.CommsCallRequest + 6, // 67: compass.v1.AgentGateway.Lifecycle:input_type -> compass.v1.LifecycleCallRequest + 39, // 68: compass.v1.AgentGateway.Publish:input_type -> compass.v1.PublishFrameRequest + 41, // 69: compass.v1.AgentGateway.PostConversationFrame:input_type -> compass.v1.PostConversationFrameRequest + 43, // 70: compass.v1.AgentGateway.Control:input_type -> compass.v1.ControlSubscribeRequest + 13, // 71: compass.v1.AgentGateway.Forge:input_type -> compass.v1.ForgeCallRequest + 34, // 72: compass.v1.AgentGateway.Board:input_type -> compass.v1.BoardCallRequest + 44, // 73: compass.v1.AgentGateway.PutSessionBlob:input_type -> compass.v1.PutSessionBlobRequest + 2, // 74: compass.v1.AgentGateway.Comms:output_type -> compass.v1.CommsCallResult + 11, // 75: compass.v1.AgentGateway.Lifecycle:output_type -> compass.v1.LifecycleCallResult + 40, // 76: compass.v1.AgentGateway.Publish:output_type -> compass.v1.PublishFrameResponse + 42, // 77: compass.v1.AgentGateway.PostConversationFrame:output_type -> compass.v1.PostConversationFrameResponse + 76, // 78: compass.v1.AgentGateway.Control:output_type -> compass.v1.AgentControl + 15, // 79: compass.v1.AgentGateway.Forge:output_type -> compass.v1.ForgeCallResult + 37, // 80: compass.v1.AgentGateway.Board:output_type -> compass.v1.BoardCallResult + 45, // 81: compass.v1.AgentGateway.PutSessionBlob:output_type -> compass.v1.PutSessionBlobResponse + 74, // [74:82] is the sub-list for method output_type + 66, // [66:74] is the sub-list for method input_type + 66, // [66:66] is the sub-list for extension type_name + 66, // [66:66] is the sub-list for extension extendee + 0, // [0:66] is the sub-list for field type_name } func init() { file_compass_v1_agent_gateway_proto_init() } @@ -4027,10 +4127,11 @@ func file_compass_v1_agent_gateway_proto_init() { (*ForgeCallRequest_Subscribe)(nil), (*ForgeCallRequest_Unsubscribe)(nil), (*ForgeCallRequest_SubmitReview)(nil), + (*ForgeCallRequest_MirrorMessages)(nil), (*ForgeCallRequest_TransitionIssueState)(nil), (*ForgeCallRequest_TransitionPullRequestState)(nil), } - file_compass_v1_agent_gateway_proto_msgTypes[13].OneofWrappers = []any{ + file_compass_v1_agent_gateway_proto_msgTypes[14].OneofWrappers = []any{ (*ForgeCallResult_Issue)(nil), (*ForgeCallResult_IssueComment)(nil), (*ForgeCallResult_Issues)(nil), @@ -4041,10 +4142,10 @@ func file_compass_v1_agent_gateway_proto_init() { (*ForgeCallResult_Error)(nil), (*ForgeCallResult_Review)(nil), } - file_compass_v1_agent_gateway_proto_msgTypes[32].OneofWrappers = []any{ + file_compass_v1_agent_gateway_proto_msgTypes[33].OneofWrappers = []any{ (*BoardCallRequest_SetIssueState)(nil), } - file_compass_v1_agent_gateway_proto_msgTypes[35].OneofWrappers = []any{ + file_compass_v1_agent_gateway_proto_msgTypes[36].OneofWrappers = []any{ (*BoardCallResult_SetIssueState)(nil), (*BoardCallResult_Error)(nil), } @@ -4054,7 +4155,7 @@ func file_compass_v1_agent_gateway_proto_init() { 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: 1, - NumMessages: 44, + NumMessages: 45, NumExtensions: 0, NumServices: 1, }, diff --git a/go/internal/mirror/drive.go b/go/internal/mirror/drive.go index 28d518b6a..3d5d9f9a8 100644 --- a/go/internal/mirror/drive.go +++ b/go/internal/mirror/drive.go @@ -36,8 +36,10 @@ var ( // ListMirrorComments returns only comments by the configured Compass App login; // implementations fail closed when they cannot resolve that account. // BodyLimit excludes the owner header; Drive reserves the row marker itself. +// session is the row's stored agent session, which an agent post's owner +// header names; it is empty on user rows. type Poster interface { - PostMirror(ctx context.Context, actor store.AccountID, need store.ForgeMirrorMode, ref refs.Ref, commentKey, clientID, body string) (forge.Comment, error) + PostMirror(ctx context.Context, actor store.AccountID, session string, need store.ForgeMirrorMode, ref refs.Ref, commentKey, clientID, body string) (forge.Comment, error) ListMirrorComments(ctx context.Context, actor store.AccountID, ref refs.Ref, since time.Time) ([]forge.Comment, error) DeleteMirrorComment(ctx context.Context, actor store.AccountID, ref refs.Ref, commentKey string) error BodyLimit(ref refs.Ref) int @@ -202,7 +204,7 @@ func deliverAttempt(ctx context.Context, st *store.Store, p Poster, token string if lostOrCanceled(ctx, leaseLost) { return false, nil } - comment, err = p.PostMirror(ctx, state.row.Actor, need, ref, commentKey, state.row.ClientCommentID, body) + comment, err = p.PostMirror(ctx, state.row.Actor, state.row.SessionID, need, ref, commentKey, state.row.ClientCommentID, body) if err == nil { return false, markDelivered(ctx, st, state.row, token, comment.ID, comment.Key, comment.URL, state.uncertain, renderedRev) } diff --git a/go/internal/mirror/drive_pgtest_test.go b/go/internal/mirror/drive_pgtest_test.go index f9c9cd7a0..f1c38600d 100644 --- a/go/internal/mirror/drive_pgtest_test.go +++ b/go/internal/mirror/drive_pgtest_test.go @@ -30,6 +30,7 @@ type postResult struct { } type recordedPost struct { + session string need store.ForgeMirrorMode key string clientID string @@ -58,9 +59,9 @@ func (p *fakePoster) BodyLimit(refs.Ref) int { return 4096 } -func (p *fakePoster) PostMirror(_ context.Context, _ store.AccountID, need store.ForgeMirrorMode, ref refs.Ref, key, clientID, body string) (forge.Comment, error) { +func (p *fakePoster) PostMirror(_ context.Context, _ store.AccountID, session string, need store.ForgeMirrorMode, ref refs.Ref, key, clientID, body string) (forge.Comment, error) { p.mu.Lock() - p.posts = append(p.posts, recordedPost{need: need, key: key, clientID: clientID, body: body}) + p.posts = append(p.posts, recordedPost{session: session, need: need, key: key, clientID: clientID, body: body}) if p.entered != nil { select { case p.entered <- struct{}{}: @@ -276,6 +277,29 @@ func TestDriveSourceTwoPostsManualAndStoresResult(t *testing.T) { } } +// The driver hands the poster the row's stored session, which the agent owner +// header names. +func TestDrivePassesRowSessionToPoster(t *testing.T) { + st, ctx, _ := openMirrorStore(t) + owner := mirrorOwner(t, ctx, st) + agent, err := st.CreateAgent(ctx, owner.ID, store.NewAgent{Handle: "mirror-session-agent", DisplayName: "Mirror Session Agent"}) + if err != nil { + t.Fatalf("CreateAgent: %v", err) + } + row, created, err := st.ClaimForgeMirror(ctx, store.ForgeMirrorRow{ + Source: 2, SourceKey: "session-topic", Actor: agent.ID, Agent: agent.ID, TargetRef: "RIG-14", BodyRev: 1, State: 1, SessionID: "sess-drive", + }) + if err != nil || !created { + t.Fatalf("ClaimForgeMirror: created=%v err=%v", created, err) + } + poster := &fakePoster{} + Drive(ctx, st, poster, []store.ForgeMirrorRow{row}, nil, clickBody) + posts, _, _ := poster.snapshot() + if len(posts) != 1 || posts[0].session != "sess-drive" || posts[0].need != store.ForgeMirrorAuto { + t.Fatalf("posts = %+v, want one auto post carrying the row session", posts) + } +} + func TestDriveRetriesThreeFailures(t *testing.T) { st, ctx, dsn := openMirrorStore(t) pool := openMirrorPool(t, ctx, dsn) diff --git a/go/internal/mirror/render_test.go b/go/internal/mirror/render_test.go index edfe2a633..040cb053a 100644 --- a/go/internal/mirror/render_test.go +++ b/go/internal/mirror/render_test.go @@ -55,7 +55,7 @@ func TestRenderBodyReservesMarkerFromProviderLimit(t *testing.T) { type bodyLimitPoster int -func (p bodyLimitPoster) PostMirror(context.Context, store.AccountID, store.ForgeMirrorMode, refs.Ref, string, string, string) (forge.Comment, error) { +func (p bodyLimitPoster) PostMirror(context.Context, store.AccountID, string, store.ForgeMirrorMode, refs.Ref, string, string, string) (forge.Comment, error) { return forge.Comment{}, nil } diff --git a/go/internal/runnerhub/relay_comms.go b/go/internal/runnerhub/relay_comms.go index 4155ea9b1..081db9b98 100644 --- a/go/internal/runnerhub/relay_comms.go +++ b/go/internal/runnerhub/relay_comms.go @@ -635,6 +635,8 @@ func (h *Hub) RelayCommsCall( if err != nil { return nil, err } + // Ask mirrors queued by this post attribute to the verified posting session. + ctx = store.WithAgentSession(ctx, req.GetSessionId()) call := req.GetCall() callID := call.GetCallId() diff --git a/go/internal/runnerhub/relay_tenant_pgtest_test.go b/go/internal/runnerhub/relay_tenant_pgtest_test.go index 19f4c977c..5f0d1ed75 100644 --- a/go/internal/runnerhub/relay_tenant_pgtest_test.go +++ b/go/internal/runnerhub/relay_tenant_pgtest_test.go @@ -88,3 +88,66 @@ func TestRunnerRelaysRunUnderTheSessionTenant(t *testing.T) { t.Fatalf("CommitConversationFrame: %v", err) } } + +// askAppendingComms appends a real ask through the store on the ctx the relay +// hands its comms caller, so the queued mirror row reads that ctx's session. +type askAppendingComms struct { + *fakeCommsCaller + st *store.Store + channel store.ChannelID + coord store.ForgeCoord + askID string +} + +func (c *askAppendingComms) PostAsAccountByName(ctx context.Context, account store.AccountID, req *compassv1.PostMessageRequest) (*compassv1.PostMessageResponse, error) { + coord := c.coord + msg, _, err := c.st.AppendMessage(ctx, store.Message{AuthorAccountID: account, Blocks: []store.MessageBlock{{Ask: &store.Ask{ + Ref: "owner/relay#7", RefCoord: &coord, + Questions: []store.AskQuestion{{QuestionID: "q1", Question: "Relay this?", Options: []store.AskOption{{ID: "yes", Label: "Yes"}}}}, + }}}}, string(c.channel), store.TopicRef{Name: "relay-asks", Create: true}, "") + if err != nil { + return nil, err + } + c.askID = msg.Blocks[0].Ask.AskID + return c.fakeCommsCaller.PostAsAccountByName(ctx, account, req) +} + +// An auto ask posted through the relay records the relaying session on its +// mirror row, so the later post names that session. +func TestRelayCommsCallStampsAskMirrorWithRelaySession(t *testing.T) { + ctx := context.Background() + st, agent, ctxB := openTenantBSession(t, ctx) + const sessionID = "sess-b" + if err := st.RecordAgentSession(ctxB, sessionID, agent.ID); err != nil { + t.Fatalf("RecordAgentSession: %v", err) + } + channel, err := st.CreateChannel(ctxB, agent.Agent.OwnerUserID, store.NewChannel{ + Kind: store.ChannelKindChannel, Name: "relay-mirror", MemberAccountIDs: []store.AccountID{agent.Agent.OwnerUserID, agent.ID}, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + coord := store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/relay", Number: 7} + if err := st.SetForgeMirrorSetting(ctxB, agent.Agent.OwnerUserID, store.ForgeMirrorSetting{Provider: coord.Provider, Host: coord.Host, Repo: coord.Repo, Mode: store.ForgeMirrorAuto}); err != nil { + t.Fatalf("SetForgeMirrorSetting: %v", err) + } + comms := &askAppendingComms{fakeCommsCaller: &fakeCommsCaller{postResp: &compassv1.PostMessageResponse{}}, st: st, channel: channel.ID, coord: coord} + hub := NewHub(&fakeLifecycleSink{}, &fakeTailSink{}, comms, discardLogger()) + hub.SetSessionBindingStore(st) + if _, err := hub.enroll(ctx, "runner-1", runnerSubject(), compassv1.RuntimeTier_RUNTIME_TIER_UNSPECIFIED, compassv1.EgressPosture_EGRESS_POSTURE_UNSPECIFIED); err != nil { + t.Fatalf("enroll Runner: %v", err) + } + if _, _, err := st.RecordSessionBinding(ctxB, sessionID, agent.ID, "runner-1"); err != nil { + t.Fatalf("RecordSessionBinding after enroll: %v", err) + } + if _, err := hub.RelayCommsCall(ctx, "runner-1", relayPost(sessionID, "c-ask", &compassv1.PostMessageRequest{})); err != nil { + t.Fatalf("RelayCommsCall: %v", err) + } + rows, err := st.AskForgeMirrors(ctxB, []string{comms.askID}) + if err != nil || len(rows) != 1 { + t.Fatalf("AskForgeMirrors = (%+v, %v), want one row", rows, err) + } + if rows[0].SessionID != sessionID { + t.Fatalf("ask mirror session = %q, want the relay session %q", rows[0].SessionID, sessionID) + } +} diff --git a/go/internal/store/context.go b/go/internal/store/context.go index 47259fbf3..c5869d770 100644 --- a/go/internal/store/context.go +++ b/go/internal/store/context.go @@ -24,3 +24,18 @@ func TenantFromContext(ctx context.Context) (TenantID, bool) { t, ok := ctx.Value(tenantContextKey{}).(TenantID) return t, ok } + +// agentSessionContextKey carries the posting agent session into store writes +// that take no session parameter, such as the ask mirrors AppendMessage queues. +type agentSessionContextKey struct{} + +// WithAgentSession returns a context carrying the verified posting session id. +func WithAgentSession(ctx context.Context, sessionID string) context.Context { + return context.WithValue(ctx, agentSessionContextKey{}, sessionID) +} + +// AgentSessionFromContext reports the posting session set on ctx, or "". +func AgentSessionFromContext(ctx context.Context) string { + id, _ := ctx.Value(agentSessionContextKey{}).(string) + return id +} diff --git a/go/internal/store/db/forge_mirrors.sql.go b/go/internal/store/db/forge_mirrors.sql.go index fa039e219..5cb0b5e37 100644 --- a/go/internal/store/db/forge_mirrors.sql.go +++ b/go/internal/store/db/forge_mirrors.sql.go @@ -17,7 +17,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload FROM forge_mirrors WHERE ask_id = ANY($1::text[]) ORDER BY created_at, id ` @@ -45,6 +45,8 @@ type AskForgeMirrorsRow struct { LeaseUntil pgtype.Timestamptz LeaseToken string UpdatedAt pgtype.Timestamptz + SessionID string + SourcePayload []byte } func (q *Queries) AskForgeMirrors(ctx context.Context, askIds []string) ([]AskForgeMirrorsRow, error) { @@ -80,6 +82,8 @@ func (q *Queries) AskForgeMirrors(ctx context.Context, askIds []string) ([]AskFo &i.LeaseUntil, &i.LeaseToken, &i.UpdatedAt, + &i.SessionID, + &i.SourcePayload, ); err != nil { return nil, err } @@ -101,7 +105,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload ` type BeginForgeMirrorAttemptParams struct { @@ -133,6 +137,8 @@ type BeginForgeMirrorAttemptRow struct { LeaseUntil pgtype.Timestamptz LeaseToken string UpdatedAt pgtype.Timestamptz + SessionID string + SourcePayload []byte } func (q *Queries) BeginForgeMirrorAttempt(ctx context.Context, arg BeginForgeMirrorAttemptParams) (BeginForgeMirrorAttemptRow, error) { @@ -162,6 +168,8 @@ func (q *Queries) BeginForgeMirrorAttempt(ctx context.Context, arg BeginForgeMir &i.LeaseUntil, &i.LeaseToken, &i.UpdatedAt, + &i.SessionID, + &i.SourcePayload, ) return i, err } @@ -177,12 +185,20 @@ func (q *Queries) BumpAskForgeMirror(ctx context.Context, askID string) error { const bumpForgeMirrorSourceRevision = `-- name: BumpForgeMirrorSourceRevision :execrows UPDATE forge_mirrors - SET body_rev = LEAST(body_rev::integer + 1, 32767)::smallint, state = 1 - WHERE id = $1 + SET body_rev = LEAST(body_rev::integer + 1, 32767)::smallint, state = 1, + source_payload = COALESCE($1::jsonb, source_payload), + session_id = CASE WHEN $2::text = '' THEN session_id ELSE $2::text END + WHERE id = $3 ` -func (q *Queries) BumpForgeMirrorSourceRevision(ctx context.Context, id string) (int64, error) { - result, err := q.db.Exec(ctx, bumpForgeMirrorSourceRevision, id) +type BumpForgeMirrorSourceRevisionParams struct { + SourcePayload []byte + SessionID string + ID string +} + +func (q *Queries) BumpForgeMirrorSourceRevision(ctx context.Context, arg BumpForgeMirrorSourceRevisionParams) (int64, error) { + result, err := q.db.Exec(ctx, bumpForgeMirrorSourceRevision, arg.SourcePayload, arg.SessionID, arg.ID) if err != nil { return 0, err } @@ -192,16 +208,17 @@ func (q *Queries) BumpForgeMirrorSourceRevision(ctx context.Context, id string) const claimForgeMirror = `-- name: ClaimForgeMirror :one INSERT INTO forge_mirrors (id, source, ask_id, source_key, actor_account_id, agent_account_id, - client_request_id, target_ref, body_rev, state) + client_request_id, target_ref, body_rev, state, session_id, source_payload) VALUES ($1, $2, $3, $4, $5, - $6, $7, $8, $9, $10) + $6, $7, $8, $9, $10, + $11, $12::jsonb) ON CONFLICT DO NOTHING RETURNING id, source, ask_id, source_key, actor_account_id, COALESCE(agent_account_id, '')::text AS agent_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload ` type ClaimForgeMirrorParams struct { @@ -215,6 +232,8 @@ type ClaimForgeMirrorParams struct { TargetRef string BodyRev int16 State int16 + SessionID string + SourcePayload []byte } type ClaimForgeMirrorRow struct { @@ -241,6 +260,8 @@ type ClaimForgeMirrorRow struct { LeaseUntil pgtype.Timestamptz LeaseToken string UpdatedAt pgtype.Timestamptz + SessionID string + SourcePayload []byte } func (q *Queries) ClaimForgeMirror(ctx context.Context, arg ClaimForgeMirrorParams) (ClaimForgeMirrorRow, error) { @@ -255,6 +276,8 @@ func (q *Queries) ClaimForgeMirror(ctx context.Context, arg ClaimForgeMirrorPara arg.TargetRef, arg.BodyRev, arg.State, + arg.SessionID, + arg.SourcePayload, ) var i ClaimForgeMirrorRow err := row.Scan( @@ -281,6 +304,8 @@ func (q *Queries) ClaimForgeMirror(ctx context.Context, arg ClaimForgeMirrorPara &i.LeaseUntil, &i.LeaseToken, &i.UpdatedAt, + &i.SessionID, + &i.SourcePayload, ) return i, err } @@ -307,7 +332,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, CASE WHEN agent_account_id = NULLIF($1::text, '') AND client_request_id = $2::text THEN 2::smallint WHEN ask_id = NULLIF($3::text, '') THEN 1::smallint @@ -354,6 +379,8 @@ type ForgeMirrorByConflictKeysRow struct { LeaseUntil pgtype.Timestamptz LeaseToken string UpdatedAt pgtype.Timestamptz + SessionID string + SourcePayload []byte ConflictKind int16 } @@ -390,6 +417,8 @@ func (q *Queries) ForgeMirrorByConflictKeys(ctx context.Context, arg ForgeMirror &i.LeaseUntil, &i.LeaseToken, &i.UpdatedAt, + &i.SessionID, + &i.SourcePayload, &i.ConflictKind, ) return i, err @@ -401,7 +430,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload FROM forge_mirrors WHERE id = $1 ` @@ -429,6 +458,8 @@ type ForgeMirrorByIDRow struct { LeaseUntil pgtype.Timestamptz LeaseToken string UpdatedAt pgtype.Timestamptz + SessionID string + SourcePayload []byte } func (q *Queries) ForgeMirrorByID(ctx context.Context, id string) (ForgeMirrorByIDRow, error) { @@ -458,6 +489,8 @@ func (q *Queries) ForgeMirrorByID(ctx context.Context, id string) (ForgeMirrorBy &i.LeaseUntil, &i.LeaseToken, &i.UpdatedAt, + &i.SessionID, + &i.SourcePayload, ) return i, err } @@ -486,8 +519,8 @@ func (q *Queries) ForgeMirrorMode(ctx context.Context, arg ForgeMirrorModeParams const insertAskForgeMirror = `-- name: InsertAskForgeMirror :exec INSERT INTO forge_mirrors - (id, source, ask_id, actor_account_id, agent_account_id, target_ref, body_rev, state) -VALUES ($1, 1, $2, $3, $3, $4, 1, 1) + (id, source, ask_id, actor_account_id, agent_account_id, target_ref, body_rev, state, session_id) +VALUES ($1, 1, $2, $3, $3, $4, 1, 1, $5) ` type InsertAskForgeMirrorParams struct { @@ -495,6 +528,7 @@ type InsertAskForgeMirrorParams struct { AskID string ActorAccountID string TargetRef string + SessionID string } func (q *Queries) InsertAskForgeMirror(ctx context.Context, arg InsertAskForgeMirrorParams) error { @@ -503,6 +537,7 @@ func (q *Queries) InsertAskForgeMirror(ctx context.Context, arg InsertAskForgeMi arg.AskID, arg.ActorAccountID, arg.TargetRef, + arg.SessionID, ) return err } @@ -517,7 +552,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload ` type LeaseForgeMirrorParams struct { @@ -550,6 +585,8 @@ type LeaseForgeMirrorRow struct { LeaseUntil pgtype.Timestamptz LeaseToken string UpdatedAt pgtype.Timestamptz + SessionID string + SourcePayload []byte } func (q *Queries) LeaseForgeMirror(ctx context.Context, arg LeaseForgeMirrorParams) (LeaseForgeMirrorRow, error) { @@ -579,6 +616,8 @@ func (q *Queries) LeaseForgeMirror(ctx context.Context, arg LeaseForgeMirrorPara &i.LeaseUntil, &i.LeaseToken, &i.UpdatedAt, + &i.SessionID, + &i.SourcePayload, ) return i, err } @@ -681,7 +720,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload FROM forge_mirrors WHERE agent_account_id = $1::text AND (state <> 2 OR recheck_at <= now()) ORDER BY created_at, id @@ -711,6 +750,8 @@ type OpenForgeMirrorsRow struct { LeaseUntil pgtype.Timestamptz LeaseToken string UpdatedAt pgtype.Timestamptz + SessionID string + SourcePayload []byte } func (q *Queries) OpenForgeMirrors(ctx context.Context, agentAccountID string) ([]OpenForgeMirrorsRow, error) { @@ -746,6 +787,8 @@ func (q *Queries) OpenForgeMirrors(ctx context.Context, agentAccountID string) ( &i.LeaseUntil, &i.LeaseToken, &i.UpdatedAt, + &i.SessionID, + &i.SourcePayload, ); err != nil { return nil, err } diff --git a/go/internal/store/db/models.go b/go/internal/store/db/models.go index 776a2a834..9075c464c 100644 --- a/go/internal/store/db/models.go +++ b/go/internal/store/db/models.go @@ -296,8 +296,10 @@ type ForgeMirror struct { SourceKey string ActorAccountID string AgentAccountID pgtype.Text + SessionID string ClientRequestID pgtype.Text TargetRef string + SourcePayload []byte BodyRev int16 PostedRev int16 State int16 diff --git a/go/internal/store/db/querier.go b/go/internal/store/db/querier.go index fa62d81d4..91eb94b89 100644 --- a/go/internal/store/db/querier.go +++ b/go/internal/store/db/querier.go @@ -92,7 +92,7 @@ type Querier interface { // WithTx), so one generated query backs both call sites. BindLifetime(ctx context.Context, arg BindLifetimeParams) (int64, error) BumpAskForgeMirror(ctx context.Context, askID string) error - BumpForgeMirrorSourceRevision(ctx context.Context, id string) (int64, error) + BumpForgeMirrorSourceRevision(ctx context.Context, arg BumpForgeMirrorSourceRevisionParams) (int64, error) // Keep handle joins optional so missing handle rows do not hide @everyone members. // reach: the author may reach agent aa ChannelAgentMembers(ctx context.Context, arg ChannelAgentMembersParams) ([]ChannelAgentMembersRow, error) diff --git a/go/internal/store/delivery_reads.go b/go/internal/store/delivery_reads.go index 5725146ec..3af02fab2 100644 --- a/go/internal/store/delivery_reads.go +++ b/go/internal/store/delivery_reads.go @@ -100,6 +100,19 @@ func (s *Store) MessageChannel(ctx context.Context, messageID string) (ChannelID return ChannelID(channel), nil } +// TopicChannel resolves a topic id to its channel. It checks no visibility: a +// caller pairs it with a membership check. An unknown id is ErrNotFound. +func (s *Store) TopicChannel(ctx context.Context, topicID string) (ChannelID, error) { + channel, err := s.q.GetTopicChannel(ctx, topicID) + if err != nil { + if noRows(err) { + return "", fmt.Errorf("%w: topic %q", ErrNotFound, topicID) + } + return "", fmt.Errorf("store: resolve topic channel: %w", err) + } + return ChannelID(channel), nil +} + // TopicChannelNames resolves a topic id to its topic name and the name of the // channel it lives in (topics.channel_id -> channels.name), the source-name // denormalization the delivery consumer stamps onto a deliver/steer control so diff --git a/go/internal/store/forge_mirrors.go b/go/internal/store/forge_mirrors.go index c3608d923..eaa833d67 100644 --- a/go/internal/store/forge_mirrors.go +++ b/go/internal/store/forge_mirrors.go @@ -54,6 +54,11 @@ type ForgeMirrorRow struct { LeaseUntil *time.Time LeaseToken string UpdatedAt time.Time + // SessionID is the agent session the owner header names; empty on user rows. + SessionID string + // SourcePayload holds an agent message mirror's render inputs, so a redrive + // after restart can rebuild the body; nil on ask and user rows. + SourcePayload []byte } // ForgeMirrorMark is the result of one mirror delivery or reconciliation pass. @@ -157,6 +162,8 @@ func (s *Store) ClaimForgeMirror(ctx context.Context, mirror ForgeMirrorRow) (Fo TargetRef: mirror.TargetRef, BodyRev: mirror.BodyRev, State: mirror.State, + SessionID: mirror.SessionID, + SourcePayload: mirror.SourcePayload, }) if err == nil { return forgeMirrorFromDB(row), true, nil @@ -169,7 +176,10 @@ func (s *Store) ClaimForgeMirror(ctx context.Context, mirror ForgeMirrorRow) (Fo return ForgeMirrorRow{}, false, fmt.Errorf("store: find claimed forge mirror: %w", err) } if conflict.ConflictKind == 3 && mirror.Source == 2 && conflict.ActorAccountID == string(mirror.Actor) && conflict.AgentAccountID == string(mirror.Agent) { - if _, err := s.q.BumpForgeMirrorSourceRevision(ctx, conflict.ID); err != nil { + // A re-mirror of the same source carries its new render inputs and session. + if _, err := s.q.BumpForgeMirrorSourceRevision(ctx, db.BumpForgeMirrorSourceRevisionParams{ + SourcePayload: mirror.SourcePayload, SessionID: mirror.SessionID, ID: conflict.ID, + }); err != nil { return ForgeMirrorRow{}, false, fmt.Errorf("store: bump forge mirror revision: %w", err) } conflict, err = s.forgeMirrorByConflictKeys(ctx, mirror) @@ -186,6 +196,7 @@ func (s *Store) ClaimForgeMirror(ctx context.Context, mirror ForgeMirrorRow) (Fo FirstAttemptAt: conflict.FirstAttemptAt, LastAttemptAt: conflict.LastAttemptAt, ClientCommentID: conflict.ClientCommentID, RecheckAt: conflict.RecheckAt, LeaseUntil: conflict.LeaseUntil, LeaseToken: conflict.LeaseToken, UpdatedAt: conflict.UpdatedAt, + SessionID: conflict.SessionID, SourcePayload: conflict.SourcePayload, }), false, nil } @@ -345,8 +356,11 @@ func insertAskMirrors(ctx context.Context, q *db.Queries, msg Message, author Ac if ForgeMirrorMode(mode) != ForgeMirrorAuto { continue } + // The relay puts the posting session on ctx; a post with none stores an + // empty session, which the poster refuses rather than misattributes. if err := q.InsertAskForgeMirror(ctx, db.InsertAskForgeMirrorParams{ ID: newID(), AskID: block.Ask.AskID, ActorAccountID: string(author), TargetRef: block.Ask.Ref, + SessionID: AgentSessionFromContext(ctx), }); err != nil { return fmt.Errorf("store: insert ask forge mirror: %w", err) } @@ -386,5 +400,6 @@ func forgeMirrorFromDB(row db.ClaimForgeMirrorRow) ForgeMirrorRow { FirstAttemptAt: optionalTime(row.FirstAttemptAt), LastAttemptAt: optionalTime(row.LastAttemptAt), ClientCommentID: row.ClientCommentID, RecheckAt: optionalTime(row.RecheckAt), LeaseUntil: optionalTime(row.LeaseUntil), LeaseToken: row.LeaseToken, UpdatedAt: row.UpdatedAt.Time, + SessionID: row.SessionID, SourcePayload: row.SourcePayload, } } diff --git a/go/internal/store/forge_mirrors_pgtest_test.go b/go/internal/store/forge_mirrors_pgtest_test.go index c763eb304..4e9c99d1b 100644 --- a/go/internal/store/forge_mirrors_pgtest_test.go +++ b/go/internal/store/forge_mirrors_pgtest_test.go @@ -581,6 +581,54 @@ func TestClaimForgeMirrorSourceTwoRequiresSourceKey(t *testing.T) { } } +// A claim stores the session and payload; a re-mirror of the same source +// replaces the payload and session and bumps the revision as an edit does. +func TestClaimForgeMirrorStoresSessionAndPayloadAndRemirrorUpdatesThem(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + agent, _ := seedAgent(t, s, "mirror-payload") + first, created, err := s.ClaimForgeMirror(ctx, ForgeMirrorRow{ + Source: 2, SourceKey: "topic-1", Actor: agent, Agent: agent, ClientRequestID: "call-1", + TargetRef: "owner/repo#3", BodyRev: 1, State: 1, SessionID: "sess-a", SourcePayload: []byte(`{"note":"one"}`), + }) + if err != nil || !created || first.SessionID != "sess-a" || string(first.SourcePayload) != `{"note": "one"}` { + t.Fatalf("ClaimForgeMirror(first) = (%+v, created=%v, err=%v), want stored session and payload", first, created, err) + } + second, created, err := s.ClaimForgeMirror(ctx, ForgeMirrorRow{ + Source: 2, SourceKey: "topic-1", Actor: agent, Agent: agent, ClientRequestID: "call-2", + TargetRef: "owner/repo#3", BodyRev: 1, State: 1, SessionID: "sess-b", SourcePayload: []byte(`{"note":"two"}`), + }) + if err != nil || created || second.ID != first.ID || second.BodyRev != 2 || second.SessionID != "sess-b" || string(second.SourcePayload) != `{"note": "two"}` { + t.Fatalf("ClaimForgeMirror(re-mirror) = (%+v, created=%v, err=%v), want bumped row with the new session and payload", second, created, err) + } +} + +// An auto ask row takes its session from the posting ctx; a post with no +// session on ctx stores an empty one for the poster to refuse. +func TestAskMirrorRowTakesSessionFromContext(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + owner := mustUser(t, s, "mirror-session-owner") + agent := mustAgent(t, s, owner.ID, "mirror-session-agent") + coord := ForgeCoord{Provider: ForgeProviderGitHub, Host: "github.com", Repo: "owner/session", Number: 4} + if err := s.SetForgeMirrorSetting(ctx, owner.ID, ForgeMirrorSetting{Provider: coord.Provider, Host: coord.Host, Repo: coord.Repo, Mode: ForgeMirrorAuto}); err != nil { + t.Fatalf("SetForgeMirrorSetting: %v", err) + } + for _, tc := range []struct{ name, session string }{{"relayed", "sess-relay"}, {"no session", ""}} { + t.Run(tc.name, func(t *testing.T) { + postCtx := ctx + if tc.session != "" { + postCtx = WithAgentSession(ctx, tc.session) + } + msg, _ := appendAskForMirrorTest(t, postCtx, s, agent.ID, mirrorTestAsk("owner/session#4", &coord), "") + rows, err := s.AskForgeMirrors(ctx, []string{msg.Blocks[0].Ask.AskID}) + if err != nil || len(rows) != 1 || rows[0].SessionID != tc.session { + t.Fatalf("AskForgeMirrors = (%+v, %v), want one row with session %q", rows, err, tc.session) + } + }) + } +} + func TestForgeMirrorExpiredLeaseFencesAttemptsAndMarks(t *testing.T) { ctx := context.Background() s := newTestStore(t) diff --git a/go/internal/store/migrations/0001_init.sql b/go/internal/store/migrations/0001_init.sql index 728d7d764..6ca2996bf 100644 --- a/go/internal/store/migrations/0001_init.sql +++ b/go/internal/store/migrations/0001_init.sql @@ -1108,8 +1108,10 @@ CREATE TABLE forge_mirrors ( source_key TEXT NOT NULL DEFAULT '', actor_account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE RESTRICT, agent_account_id TEXT REFERENCES agent_accounts (account_id) ON DELETE RESTRICT, + session_id TEXT NOT NULL DEFAULT '', client_request_id TEXT, target_ref TEXT NOT NULL CHECK (target_ref <> ''), + source_payload JSONB, body_rev SMALLINT NOT NULL DEFAULT 1, posted_rev SMALLINT NOT NULL DEFAULT 0, state SMALLINT NOT NULL DEFAULT 1 CHECK (state IN (1, 2, 3)), diff --git a/go/internal/store/queries/forge_mirrors.sql b/go/internal/store/queries/forge_mirrors.sql index 67da9a800..9ecfe75be 100644 --- a/go/internal/store/queries/forge_mirrors.sql +++ b/go/internal/store/queries/forge_mirrors.sql @@ -31,16 +31,17 @@ ON CONFLICT (tenant_id, forge_provider, forge_host, repo) DO UPDATE -- name: ClaimForgeMirror :one INSERT INTO forge_mirrors (id, source, ask_id, source_key, actor_account_id, agent_account_id, - client_request_id, target_ref, body_rev, state) + client_request_id, target_ref, body_rev, state, session_id, source_payload) VALUES (sqlc.arg(id), sqlc.arg(source), sqlc.arg(ask_id), sqlc.arg(source_key), sqlc.arg(actor_account_id), - sqlc.narg(agent_account_id), sqlc.narg(client_request_id), sqlc.arg(target_ref), sqlc.arg(body_rev), sqlc.arg(state)) + sqlc.narg(agent_account_id), sqlc.narg(client_request_id), sqlc.arg(target_ref), sqlc.arg(body_rev), sqlc.arg(state), + sqlc.arg(session_id), sqlc.narg(source_payload)::jsonb) ON CONFLICT DO NOTHING RETURNING id, source, ask_id, source_key, actor_account_id, COALESCE(agent_account_id, '')::text AS agent_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at; + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload; -- name: ForgeMirrorByConflictKeys :one SELECT id, source, ask_id, source_key, actor_account_id, @@ -48,7 +49,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, CASE WHEN agent_account_id = NULLIF(sqlc.arg(agent_account_id)::text, '') AND client_request_id = sqlc.narg(client_request_id)::text THEN 2::smallint WHEN ask_id = NULLIF(sqlc.arg(ask_id)::text, '') THEN 1::smallint @@ -64,7 +65,9 @@ SELECT id, source, ask_id, source_key, actor_account_id, -- name: BumpForgeMirrorSourceRevision :execrows UPDATE forge_mirrors - SET body_rev = LEAST(body_rev::integer + 1, 32767)::smallint, state = 1 + SET body_rev = LEAST(body_rev::integer + 1, 32767)::smallint, state = 1, + source_payload = COALESCE(sqlc.narg(source_payload)::jsonb, source_payload), + session_id = CASE WHEN sqlc.arg(session_id)::text = '' THEN session_id ELSE sqlc.arg(session_id)::text END WHERE id = sqlc.arg(id); -- name: OpenForgeMirrors :many @@ -73,7 +76,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload FROM forge_mirrors WHERE agent_account_id = sqlc.arg(agent_account_id)::text AND (state <> 2 OR recheck_at <= now()) ORDER BY created_at, id; @@ -84,7 +87,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload FROM forge_mirrors WHERE ask_id = ANY(sqlc.arg(ask_ids)::text[]) ORDER BY created_at, id; -- name: LeaseForgeMirror :one @@ -97,7 +100,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at; + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload; -- name: RenewForgeMirror :execrows UPDATE forge_mirrors SET lease_until = now() + sqlc.arg(ttl)::interval @@ -113,7 +116,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at; + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload; -- name: MarkForgeMirror :execrows UPDATE forge_mirrors @@ -135,13 +138,13 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload FROM forge_mirrors WHERE id = sqlc.arg(id); -- name: InsertAskForgeMirror :exec INSERT INTO forge_mirrors - (id, source, ask_id, actor_account_id, agent_account_id, target_ref, body_rev, state) -VALUES (sqlc.arg(id), 1, sqlc.arg(ask_id), sqlc.arg(actor_account_id), sqlc.arg(actor_account_id), sqlc.arg(target_ref), 1, 1); + (id, source, ask_id, actor_account_id, agent_account_id, target_ref, body_rev, state, session_id) +VALUES (sqlc.arg(id), 1, sqlc.arg(ask_id), sqlc.arg(actor_account_id), sqlc.arg(actor_account_id), sqlc.arg(target_ref), 1, 1, sqlc.arg(session_id)); -- name: BumpAskForgeMirror :exec UPDATE forge_mirrors SET body_rev = 2, state = 1 WHERE ask_id = sqlc.arg(ask_id); diff --git a/go/server/forge.go b/go/server/forge.go index db1cb4178..3a687d462 100644 --- a/go/server/forge.go +++ b/go/server/forge.go @@ -231,6 +231,8 @@ func (s *forgeService) ExecuteForgeCallAsAccount( return s.subscribeForge(ctx, caller, call, c.Subscribe), nil case *compassv1internal.ForgeCallRequest_Unsubscribe: return s.unsubscribeForge(ctx, caller, c.Unsubscribe), nil + case *compassv1internal.ForgeCallRequest_MirrorMessages: + return s.mirrorMessages(ctx, caller, sessionID, call, c.MirrorMessages), nil default: return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("forge: call has no operation variant set")) } diff --git a/go/server/forge_mirror.go b/go/server/forge_mirror.go index 39cc928bf..daf8b4f3c 100644 --- a/go/server/forge_mirror.go +++ b/go/server/forge_mirror.go @@ -26,11 +26,12 @@ import ( "github.com/RigelBuild/compass/go/internal/store" ) -// Owner-header session id for agent mirror comments; user mirrors use the -// render attribution and do not carry an owner header. -const mirrorAgentSession = "mirror" +// maxOwnerSessionLen is the owner-header field grammar's longest value. Agent +// mirror rows stamp their stored session, so the body budget reserves the +// widest session the header can carry. +const maxOwnerSessionLen = 39 -var mirrorOwnerHeaderAllowance = forge.MaxOwnerHeaderLen(mirrorAgentSession) +var mirrorOwnerHeaderAllowance = forge.MaxOwnerHeaderLen(strings.Repeat("a", maxOwnerSessionLen)) var _ mirror.Poster = (*forgeService)(nil) @@ -127,7 +128,8 @@ func (s *forgeService) resolveMirrorCoordinate(ref refs.Ref) (resolvedForge, str } return rf, repo, ref.Number, nil } -func (s *forgeService) PostMirror(ctx context.Context, actor store.AccountID, need store.ForgeMirrorMode, ref refs.Ref, commentKey, clientID, body string) (forge.Comment, error) { + +func (s *forgeService) PostMirror(ctx context.Context, actor store.AccountID, session string, need store.ForgeMirrorMode, ref refs.Ref, commentKey, clientID, body string) (forge.Comment, error) { account, err := s.store.GetAccount(ctx, actor) if err != nil { return forge.Comment{}, fmt.Errorf("mirror: read caller: %w", err) @@ -138,7 +140,12 @@ func (s *forgeService) PostMirror(ctx context.Context, actor store.AccountID, ne } switch { case account.IsAgent(): - identity, identityErr := s.resolveIdentity(ctx, actor, mirrorAgentSession) + // An agent post names the session that asked for it; with none recorded + // there is nothing true to stamp, so the row fails rather than posts. + if session == "" { + return forge.Comment{}, &mirrorRefusalError{code: connect.CodeFailedPrecondition, message: "mirror: agent row has no posting session"} + } + identity, identityErr := s.resolveIdentity(ctx, actor, session) if identityErr != nil { return forge.Comment{}, fmt.Errorf("mirror: resolve agent attribution: %s", identityErr.GetMessage()) } @@ -154,20 +161,34 @@ func (s *forgeService) PostMirror(ctx context.Context, actor store.AccountID, ne if commentKey != "" { return rf.author.EditComment(ctx, repo, commentKey, body) } - if ref.Kind == refs.KindForge { - rows, err := s.store.PullRequestsByCoords(ctx, []store.ForgeCoord{{Provider: store.ForgeProvider(rf.provider), Host: rf.host, Repo: repo, Number: number}}) - if err != nil { - return forge.Comment{}, fmt.Errorf("mirror: look up pull request: %w", err) - } - for _, row := range rows { - if row.Coord.Number == number { - return rf.author.CommentOnPullRequest(ctx, repo, number, body, clientID) - } - } + isPR, err := s.mirrorTargetIsPullRequest(ctx, rf, repo, ref) + if err != nil { + return forge.Comment{}, err + } + if isPR { + return rf.author.CommentOnPullRequest(ctx, repo, number, body, clientID) } return rf.author.CommentOnIssue(ctx, repo, number, body, clientID) } +// mirrorTargetIsPullRequest reports whether a GitHub target has an ingested +// pull_requests row, which routes the post and the tool's result arm. +func (s *forgeService) mirrorTargetIsPullRequest(ctx context.Context, rf resolvedForge, repo string, ref refs.Ref) (bool, error) { + if ref.Kind != refs.KindForge { + return false, nil + } + rows, err := s.store.PullRequestsByCoords(ctx, []store.ForgeCoord{{Provider: store.ForgeProvider(rf.provider), Host: rf.host, Repo: repo, Number: ref.Number}}) + if err != nil { + return false, fmt.Errorf("mirror: look up pull request: %w", err) + } + for _, row := range rows { + if row.Coord.Number == ref.Number { + return true, nil + } + } + return false, nil +} + func (s *forgeService) ListMirrorComments(ctx context.Context, actor store.AccountID, ref refs.Ref, since time.Time) ([]forge.Comment, error) { account, err := s.store.GetAccount(ctx, actor) if err != nil { @@ -407,6 +428,9 @@ func (s *forgeService) driveMirrorRow(ctx context.Context, rowID string, bodyFn if row.Source == 2 && bodyFn == nil && row.AskID != "" { bodyFn = s.userAskBodyForRow() } + if row.Source == 2 && bodyFn == nil && row.Agent != "" && row.SourcePayload != nil { + bodyFn = s.agentMessagesBodyForRow() + } poster := mirror.Poster(userMirrorPoster{s}) if row.Agent != "" { poster = agentMirrorPoster{s} @@ -533,39 +557,76 @@ func (s *forgeService) MirrorAsUser(ctx context.Context, account store.Account, return s.claimAndDriveUserMirror(ctx, account, ref, sourceKey, renderBody, fail) } -func (s *forgeService) resolveUserMirrorSource(ctx context.Context, account store.Account, req *compassv1.MirrorToForgeRequest, channel string) (string, mirror.BodyFunc, error) { - if req.GetTopicId() != "" { - return s.resolveUserTopicSource(ctx, account, req, channel) +type resolvedMirrorMessages struct { + sourceKey string + messages []store.Message + link mirror.Link + note string +} + +func (s *forgeService) resolveMirrorMessages(ctx context.Context, actor store.AccountID, channelID, channelName, topicID string, messageIDs []string) (resolvedMirrorMessages, error) { + if (topicID == "") == (len(messageIDs) == 0) || len(messageIDs) > 50 { + return resolvedMirrorMessages{}, connect.NewError(connect.CodeInvalidArgument, errors.New("exactly one topic_id or 1 to 50 message ids are required")) } - ids := append([]string(nil), req.GetMessageIds()...) + if topicID != "" { + topics, err := s.mirrorStoreDB.ListTopics(ctx, string(actor), channelID, true) + if err != nil { + return resolvedMirrorMessages{}, mirrorStoreError(err) + } + for _, topic := range topics { + if topic.ID != topicID { + continue + } + messages, err := s.mirrorStoreDB.ListMessages(ctx, store.ListMessagesQuery{Actor: actor, ChannelID: store.ChannelID(channelID), TopicID: topic.ID, Page: store.Page{Limit: 51}}) + if err != nil { + return resolvedMirrorMessages{}, mirrorStoreError(err) + } + if messages == nil { + messages = []store.Message{} + } + showLimitNote := len(messages) > 50 + if showLimitNote { + messages = append([]store.Message(nil), messages[:50]...) + } + slices.Reverse(messages) + note := "" + if showLimitNote { + note = "Showing the newest 50 messages." + } + link := mirror.Link{Channel: channelName, Topic: topic.Name, URL: topicLinkFor(s.publicURL, channelID, topic.ID)} + return resolvedMirrorMessages{sourceKey: topic.ID, messages: messages, link: link, note: note}, nil + } + return resolvedMirrorMessages{}, connect.NewError(connect.CodeNotFound, store.ErrNotFound) + } + ids := append([]string(nil), messageIDs...) slices.Sort(ids) for i, id := range ids { if id == "" || i > 0 && id == ids[i-1] { - return "", nil, connect.NewError(connect.CodeInvalidArgument, errors.New("message ids must be non-empty and unique")) + return resolvedMirrorMessages{}, connect.NewError(connect.CodeInvalidArgument, errors.New("message ids must be non-empty and unique")) } } hash := sha256.Sum256([]byte(strings.Join(ids, "\n"))) messages := make([]store.Message, 0, len(ids)) for _, id := range ids { messageChannel, err := s.mirrorStoreDB.MessageChannel(ctx, id) - if errors.Is(err, store.ErrNotFound) || err == nil && messageChannel != store.ChannelID(req.GetChannelId()) { - return "", nil, connect.NewError(connect.CodeNotFound, store.ErrNotFound) + if errors.Is(err, store.ErrNotFound) || err == nil && messageChannel != store.ChannelID(channelID) { + return resolvedMirrorMessages{}, connect.NewError(connect.CodeNotFound, store.ErrNotFound) } if err != nil { - return "", nil, mirrorStoreError(err) + return resolvedMirrorMessages{}, mirrorStoreError(err) } message, err := s.mirrorStoreDB.MessageByID(ctx, id) if err != nil { - return "", nil, mirrorStoreError(err) + return resolvedMirrorMessages{}, mirrorStoreError(err) } messages = append(messages, message) } - member, err := s.mirrorStoreDB.IsChannelMember(ctx, account.ID, store.ChannelID(req.GetChannelId())) + member, err := s.mirrorStoreDB.IsChannelMember(ctx, actor, store.ChannelID(channelID)) if err != nil { - return "", nil, mirrorStoreError(err) + return resolvedMirrorMessages{}, mirrorStoreError(err) } if !member { - return "", nil, connect.NewError(connect.CodeNotFound, store.ErrNotFound) + return resolvedMirrorMessages{}, connect.NewError(connect.CodeNotFound, store.ErrNotFound) } sort.Slice(messages, func(i, j int) bool { if messages[i].At.Equal(messages[j].At) { @@ -573,41 +634,16 @@ func (s *forgeService) resolveUserMirrorSource(ctx context.Context, account stor } return messages[i].At.Before(messages[j].At) }) - key := hex.EncodeToString(hash[:]) - link := mirror.Link{Channel: channel, Topic: "selected messages", URL: deepLinkFor(s.publicURL, req.GetChannelId())} - return key, userMessageBody(messages, messageAuthors(messages), link, account.Handle, ""), nil - + link := mirror.Link{Channel: channelName, Topic: "selected messages", URL: deepLinkFor(s.publicURL, channelID)} + return resolvedMirrorMessages{sourceKey: hex.EncodeToString(hash[:]), messages: messages, link: link}, nil } -func (s *forgeService) resolveUserTopicSource(ctx context.Context, account store.Account, req *compassv1.MirrorToForgeRequest, channel string) (string, mirror.BodyFunc, error) { - topics, err := s.mirrorStoreDB.ListTopics(ctx, string(account.ID), req.GetChannelId(), true) +func (s *forgeService) resolveUserMirrorSource(ctx context.Context, account store.Account, req *compassv1.MirrorToForgeRequest, channel string) (string, mirror.BodyFunc, error) { + resolved, err := s.resolveMirrorMessages(ctx, account.ID, req.GetChannelId(), channel, req.GetTopicId(), req.GetMessageIds()) if err != nil { - return "", nil, mirrorStoreError(err) - } - for _, topic := range topics { - if topic.ID != req.GetTopicId() { - continue - } - messages, err := s.mirrorStoreDB.ListMessages(ctx, store.ListMessagesQuery{Actor: account.ID, ChannelID: store.ChannelID(req.GetChannelId()), TopicID: topic.ID, Page: store.Page{Limit: 51}}) - if err != nil { - return "", nil, mirrorStoreError(err) - } - if messages == nil { - messages = []store.Message{} - } - showLimitNote := len(messages) > 50 - if showLimitNote { - messages = append([]store.Message(nil), messages[:50]...) - } - slices.Reverse(messages) - note := "" - if showLimitNote { - note = "Showing the newest 50 messages." - } - link := mirror.Link{Channel: channel, Topic: topic.Name, URL: topicLinkFor(s.publicURL, req.GetChannelId(), topic.ID)} - return topic.ID, userMessageBody(messages, messageAuthors(messages), link, account.Handle, note), nil + return "", nil, err } - return "", nil, connect.NewError(connect.CodeNotFound, store.ErrNotFound) + return resolved.sourceKey, userMessageBody(resolved.messages, messageAuthors(resolved.messages), resolved.link, account.Handle, resolved.note), nil } func userMessageBody(messages []store.Message, authors map[store.AccountID]string, link mirror.Link, handle, note string) mirror.BodyFunc { @@ -690,11 +726,37 @@ func (s *forgeService) claimAndDriveUserMirror(ctx context.Context, account stor } return s.clickDrive(ctx, row, bodyFn, fail) } + func (s *forgeService) clickDrive(ctx context.Context, row store.ForgeMirrorRow, bodyFn mirror.BodyFunc, fail func(connect.Code, error) (string, error)) (string, error) { + result, ok := s.awaitMirrorDrive(ctx, row, bodyFn) + if !ok { + return fail(connect.CodeUnavailable, errors.New("mirror still delivering")) + } + if result.row.State == 3 { + return fail(mirrorFailureCode(result.err), result.err) + } + if result.err != nil { + return "", connect.NewError(connect.CodeInternal, result.err) + } + if result.row.State != 2 { + return fail(connect.CodeUnavailable, errors.New("mirror row was not delivered")) + } + return result.row.CommentURL, nil +} + +type mirrorDriveResult struct { + row store.ForgeMirrorRow + err error +} + +// awaitMirrorDrive drives row under the mirror lifetime, detached from ctx so a +// caller that stops waiting never strands a POST. ok is false when the server +// is closing or ctx ends first; delivery then continues in the background. +func (s *forgeService) awaitMirrorDrive(ctx context.Context, row store.ForgeMirrorRow, bodyFn mirror.BodyFunc) (mirrorDriveResult, bool) { s.mirrorMu.Lock() if s.mirrorClosing { s.mirrorMu.Unlock() - return fail(connect.CodeUnavailable, errors.New("mirror still delivering")) + return mirrorDriveResult{}, false } done := s.mirrorDone if done == nil { @@ -708,11 +770,7 @@ func (s *forgeService) clickDrive(ctx context.Context, row store.ForgeMirrorRow, workCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) stopLife := cancelOnDone(done, cancel) - type driveResult struct { - row store.ForgeMirrorRow - err error - } - resultCh := make(chan driveResult, 1) + resultCh := make(chan mirrorDriveResult, 1) go func() { defer s.mirrorWG.Done() defer cancel() @@ -733,34 +791,31 @@ func (s *forgeService) clickDrive(ctx context.Context, row store.ForgeMirrorRow, slog.Default().ErrorContext(workCtx, "drive user forge mirror", "mirror_id", row.ID, "error", err) } s.scheduleMirrorRecheck(workCtx, result, bodyFn, err != nil) - resultCh <- driveResult{row: result, err: err} + resultCh <- mirrorDriveResult{row: result, err: err} }() select { case <-ctx.Done(): - return fail(connect.CodeUnavailable, errors.New("mirror still delivering")) + return mirrorDriveResult{}, false case result := <-resultCh: if ctx.Err() != nil { - return fail(connect.CodeUnavailable, errors.New("mirror still delivering")) - } - if result.row.State == 3 { - if refusal, ok := errors.AsType[*mirrorRefusalError](result.err); ok { - return fail(refusal.code, result.err) - } - if errors.Is(result.err, forge.ErrBodyTooLarge) { - return fail(connect.CodeInvalidArgument, result.err) - } - if errors.Is(result.err, store.ErrLeaseLost) { - return fail(connect.CodeUnavailable, result.err) - } - return fail(connect.CodeInternal, result.err) - } - if result.err != nil { - return "", connect.NewError(connect.CodeInternal, result.err) - } - if result.row.State != 2 { - return fail(connect.CodeUnavailable, errors.New("mirror row was not delivered")) + return mirrorDriveResult{}, false } - return result.row.CommentURL, nil + return result, true + } +} + +// mirrorFailureCode maps a failed row's drive error to the caller's status. +func mirrorFailureCode(err error) connect.Code { + if refusal, ok := errors.AsType[*mirrorRefusalError](err); ok { + return refusal.code + } + switch { + case errors.Is(err, forge.ErrBodyTooLarge): + return connect.CodeInvalidArgument + case errors.Is(err, store.ErrLeaseLost): + return connect.CodeUnavailable + default: + return connect.CodeInternal } } diff --git a/go/server/forge_mirror_agent.go b/go/server/forge_mirror_agent.go new file mode 100644 index 000000000..a64768248 --- /dev/null +++ b/go/server/forge_mirror_agent.go @@ -0,0 +1,213 @@ +//go:build unix + +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + + "connectrpc.com/connect" + + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/mirror" + "github.com/RigelBuild/compass/go/internal/refs" + "github.com/RigelBuild/compass/go/internal/store" +) + +// agentMirrorPayload is an agent message mirror's render inputs, stored on the +// row so a redrive after restart rebuilds the same body from the message store. +type agentMirrorPayload struct { + ChannelID string `json:"channel_id"` + TopicID string `json:"topic_id,omitempty"` + MessageIDs []string `json:"message_ids,omitempty"` + Note string `json:"note,omitempty"` +} + +// mirrorMessages is the agent mirror tool: it checks the source and target, +// claims a row keyed by the call's client_request_id, and drives it. A retry +// with the same key returns the first row's outcome and never posts again. +func (s *forgeService) mirrorMessages(ctx context.Context, caller store.AccountID, sessionID string, call *compassv1internal.ForgeCallRequest, req *compassv1internal.MirrorMessagesRequest) *compassv1internal.ForgeCallResult { + topicID, messageIDs := req.GetTopicId(), req.GetMessageIds() + if (topicID == "") == (len(messageIDs) == 0) { + return forgeErrorResult(forgeErr(connect.CodeInvalidArgument, "forge mirror: exactly one of message_ids or topic_id is required")) + } + if len(messageIDs) > 50 { + return forgeErrorResult(forgeErr(connect.CodeInvalidArgument, "forge mirror: message_ids must contain 1 to 50 ids")) + } + ref, err := refs.Parse(req.GetRef()) + if err != nil { + return forgeErrorResult(forgeErr(connect.CodeInvalidArgument, "forge mirror: "+err.Error())) + } + if s.mirrorStoreDB == nil { + return forgeErrorResult(forgeErr(connect.CodeUnavailable, "forge mirror: message store is not configured")) + } + account, err := s.store.GetAccount(ctx, caller) + if err != nil { + return forgeErrorResult(storeForgeError(err)) + } + if !account.IsAgent() { + return forgeErrorResult(forgeErr(connect.CodeInvalidArgument, "forge mirror: caller must be an agent")) + } + rf, repo, _, fe := s.mirrorAllowed(ctx, account, store.ForgeMirrorAuto, ref) + if fe != nil { + return forgeErrorResult(fe) + } + channelID, fe := s.mirrorSourceChannel(ctx, caller, req.GetChannel(), topicID, messageIDs) + if fe != nil { + return forgeErrorResult(fe) + } + payload := agentMirrorPayload{ChannelID: string(channelID), TopicID: topicID, Note: req.GetNote()} + if len(messageIDs) > 0 { + payload.MessageIDs = slices.Sorted(slices.Values(messageIDs)) + } + resolved, err := s.resolveAgentMirror(ctx, caller, payload) + if err != nil { + return forgeErrorResult(mirrorSourceError(err)) + } + raw, err := json.Marshal(payload) + if err != nil { + return forgeErrorResult(forgeErr(connect.CodeInternal, "forge mirror: encode source payload: "+err.Error())) + } + key := call.GetClientRequestId() + row, created, err := s.mirrorStoreDB.ClaimForgeMirror(ctx, store.ForgeMirrorRow{ + Source: 2, SourceKey: resolved.sourceKey, Actor: caller, Agent: caller, ClientRequestID: key, + TargetRef: ref.String(), BodyRev: 1, State: 1, SessionID: sessionID, SourcePayload: raw, + }) + if err != nil { + return forgeErrorResult(storeForgeError(err)) + } + if !created { + if row.Source != 2 || row.Actor != caller || row.Agent != caller { + return forgeErrorResult(forgeErr(connect.CodeFailedPrecondition, "forge mirror: source conflicts with another actor")) + } + // Only the claiming call posts; a retry reports the row as it stands. + if key != "" && row.ClientRequestID == key { + return s.mirrorRowResult(ctx, rf, repo, ref, row, nil) + } + if row.SourceKey != resolved.sourceKey || row.TargetRef != ref.String() { + return forgeErrorResult(forgeErr(connect.CodeFailedPrecondition, "forge mirror: source conflicts with another mirror")) + } + } + result, ok := s.awaitMirrorDrive(ctx, row, s.agentMessagesBodyForRow()) + if !ok { + return forgeErrorResult(forgeErr(connect.CodeUnavailable, "forge mirror: still delivering")) + } + return s.mirrorRowResult(ctx, rf, repo, ref, result.row, result.err) +} + +// mirrorSourceChannel returns the channel holding the source. A named channel +// resolves for the caller as the comms tools resolve names, and must hold the +// source; every miss is the same not_found, so an invisible channel reads as +// an unknown one. +func (s *forgeService) mirrorSourceChannel(ctx context.Context, caller store.AccountID, name, topicID string, messageIDs []string) (store.ChannelID, *compassv1internal.ForgeCallError) { + var derived store.ChannelID + var err error + switch { + case topicID != "": + derived, err = s.mirrorStoreDB.TopicChannel(ctx, topicID) + case len(messageIDs) > 0: + derived, err = s.mirrorStoreDB.MessageChannel(ctx, messageIDs[0]) + default: + return "", forgeErr(connect.CodeInvalidArgument, "forge mirror: provide exactly one of topic_id or message_ids") + } + if err != nil { + return "", mirrorSourceError(err) + } + if name != "" { + ch, err := s.mirrorStoreDB.ChannelByNameForViewer(ctx, caller, name) + if err != nil { + return "", mirrorSourceError(err) + } + if ch.ID != derived { + return "", mirrorSourceNotFound() + } + } + return derived, nil +} + +// resolveAgentMirror runs the shared source resolution with the agent as +// actor, so both the call and every redrive re-check visibility. +func (s *forgeService) resolveAgentMirror(ctx context.Context, agent store.AccountID, payload agentMirrorPayload) (resolvedMirrorMessages, error) { + resolved, err := s.resolveMirrorMessages(ctx, agent, payload.ChannelID, "", payload.TopicID, payload.MessageIDs) + if err != nil { + return resolvedMirrorMessages{}, err + } + channel, err := s.mirrorStoreDB.GetChannel(ctx, store.ChannelID(payload.ChannelID)) + if err != nil { + return resolvedMirrorMessages{}, mirrorStoreError(err) + } + resolved.link.Channel = channel.Name + return resolved, nil +} + +// agentMessagesBodyForRow renders an agent message row from its stored +// payload. A source the agent can no longer see is a refusal: the row fails +// and is not posted. +func (s *forgeService) agentMessagesBodyForRow() mirror.BodyFunc { + return func(ctx context.Context, row store.ForgeMirrorRow, limit int) (string, error) { + var payload agentMirrorPayload + if err := json.Unmarshal(row.SourcePayload, &payload); err != nil { + return "", fmt.Errorf("mirror: decode source payload: %w", err) + } + resolved, err := s.resolveAgentMirror(ctx, row.Agent, payload) + if connect.CodeOf(err) == connect.CodeNotFound { + return "", &mirrorRefusalError{code: connect.CodeNotFound, message: "mirror: source is no longer visible to the agent"} + } + if err != nil { + return "", err + } + note := payload.Note + if resolved.note != "" { + if note != "" { + note += "\n\n" + } + note += resolved.note + } + body := mirror.Render(resolved.messages, messageAuthors(resolved.messages), resolved.link, note, limit) + return truncateBody(body, limit), nil + } +} + +// mirrorRowResult maps a row to the tool result: its comment once done, the +// stored error once failed, and unavailable while it is still pending. +func (s *forgeService) mirrorRowResult(ctx context.Context, rf resolvedForge, repo string, ref refs.Ref, row store.ForgeMirrorRow, driveErr error) *compassv1internal.ForgeCallResult { + switch row.State { + case 2: + isPR, err := s.mirrorTargetIsPullRequest(ctx, rf, repo, ref) + if err != nil { + return forgeErrorResult(forgeErr(connect.CodeInternal, err.Error())) + } + comment := &compassv1internal.CommentRef{Url: row.CommentURL, CommentId: row.CommentID} + if isPR { + return &compassv1internal.ForgeCallResult{Result: &compassv1internal.ForgeCallResult_PrComment{PrComment: comment}} + } + return &compassv1internal.ForgeCallResult{Result: &compassv1internal.ForgeCallResult_IssueComment{IssueComment: comment}} + case 3: + code := connect.CodeFailedPrecondition + if driveErr != nil { + code = mirrorFailureCode(driveErr) + } + return forgeErrorResult(forgeErr(code, row.LastError)) + default: + return forgeErrorResult(forgeErr(connect.CodeUnavailable, "forge mirror: still delivering")) + } +} + +// mirrorSourceError maps a source lookup error; every not-found collapses to +// one message so an unknown and an invisible source are indistinguishable. +func mirrorSourceError(err error) *compassv1internal.ForgeCallError { + if errors.Is(err, store.ErrNotFound) || connect.CodeOf(err) == connect.CodeNotFound { + return mirrorSourceNotFound() + } + if connectErr, ok := errors.AsType[*connect.Error](err); ok { + return forgeErr(connectErr.Code(), connectErr.Message()) + } + return storeForgeError(err) +} + +func mirrorSourceNotFound() *compassv1internal.ForgeCallError { + return forgeErr(connect.CodeNotFound, "forge mirror: source not found") +} diff --git a/go/server/forge_mirror_agent_pgtest_test.go b/go/server/forge_mirror_agent_pgtest_test.go new file mode 100644 index 000000000..a638217eb --- /dev/null +++ b/go/server/forge_mirror_agent_pgtest_test.go @@ -0,0 +1,356 @@ +//go:build pgtest && unix + +package server + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + + "github.com/RigelBuild/compass/go/internal/forge" + compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/pgtest" + "github.com/RigelBuild/compass/go/internal/store" +) + +const agentMirrorSession = "sess-mirror-1" + +// agentMirrorFixture is one tenant with an agent that can see one channel, +// a topic of messages in it, a channel it cannot see, and an auto target it +// holds scope on. +type agentMirrorFixture struct { + ctx context.Context + dsn string + st *store.Store + owner store.Account + agent store.Account + channel store.Channel + topicID string + msgIDs []string + hidden store.Channel + hiddenMsg string + hiddenTop string + author *forge.FakeProvider + svc *forgeService +} + +func newAgentMirrorFixture(t *testing.T, texts ...string) *agentMirrorFixture { + t.Helper() + dsn := pgtest.RequireDSN(t) + f := &agentMirrorFixture{dsn: dsn, st: openAgentMirrorStore(t, dsn)} + f.ctx = store.WithTenant(context.Background(), f.st.EffectiveTenant(context.Background())) + var err error + if f.owner, err = f.st.CreateUser(f.ctx, store.NewUser{Handle: "mirror-tool-owner", DisplayName: "Mirror Tool Owner"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if f.agent, err = f.st.CreateAgent(f.ctx, f.owner.ID, store.NewAgent{Handle: "mirror-tool-agent", DisplayName: "Mirror Tool Agent"}); err != nil { + t.Fatalf("CreateAgent: %v", err) + } + if f.channel, err = f.st.CreateChannel(f.ctx, f.owner.ID, store.NewChannel{Name: "mirror-tool", Kind: store.ChannelKindChannel, MemberAccountIDs: []store.AccountID{f.owner.ID, f.agent.ID}}); err != nil { + t.Fatalf("CreateChannel: %v", err) + } + if len(texts) == 0 { + texts = []string{"first message", "second message"} + } + for _, text := range texts { + msg := f.appendText(t, f.channel.ID, f.owner.ID, "mirror-thread", text) + f.topicID = msg.TopicID + f.msgIDs = append(f.msgIDs, string(msg.ID)) + } + other, err := f.st.CreateUser(f.ctx, store.NewUser{Handle: "mirror-tool-other", DisplayName: "Mirror Tool Other"}) + if err != nil { + t.Fatalf("CreateUser(other): %v", err) + } + if f.hidden, err = f.st.CreateChannel(f.ctx, other.ID, store.NewChannel{Name: "mirror-hidden", Kind: store.ChannelKindChannel, MemberAccountIDs: []store.AccountID{other.ID}}); err != nil { + t.Fatalf("CreateChannel(hidden): %v", err) + } + hidden := f.appendText(t, f.hidden.ID, other.ID, "hidden-thread", "hidden message") + f.hiddenMsg, f.hiddenTop = string(hidden.ID), hidden.TopicID + for _, repo := range []string{"owner/auto", "owner/noscope"} { + if err := f.st.SetForgeMirrorSetting(f.ctx, f.owner.ID, store.ForgeMirrorSetting{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: repo, Mode: store.ForgeMirrorAuto}); err != nil { + t.Fatalf("SetForgeMirrorSetting(%s): %v", repo, err) + } + } + if err := f.st.SetForgeMirrorSetting(f.ctx, f.owner.ID, store.ForgeMirrorSetting{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/manual", Mode: store.ForgeMirrorManual}); err != nil { + t.Fatalf("SetForgeMirrorSetting(manual): %v", err) + } + for _, repo := range []string{"owner/auto", "owner/manual", "owner/off"} { + if err := f.st.GrantForgeScope(f.ctx, store.ForgeScope{AccountID: f.owner.ID, Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: repo}); err != nil { + t.Fatalf("GrantForgeScope(%s): %v", repo, err) + } + } + f.author = forge.NewFakeProvider("gh-author") + f.author.CommentResult = forge.Comment{ID: 21, Key: "tool-comment", URL: "https://github.com/owner/auto/issues/12#issuecomment-21"} + f.author.EditCommentResult = f.author.CommentResult + f.svc = f.service(t, f.st) + return f +} + +func openAgentMirrorStore(t *testing.T, dsn string) *store.Store { + t.Helper() + st, err := store.Open(context.Background(), dsn) // test root context + if err != nil { + t.Fatalf("store Open: %v", err) + } + t.Cleanup(st.Close) + return st +} + +// service builds a forgeService over st with the fixture's fake author, as +// serve assembly does. +func (f *agentMirrorFixture) service(t *testing.T, st *store.Store) *forgeService { + t.Helper() + svc, _ := newForgeServiceForTest(t, f.author, forge.NewFakeProvider("gh-reviewer")) + svc.store = st + svc.mirrorStoreDB = st + svc.enforceScopes = true + t.Cleanup(svc.StopMirrorLifetime) + return svc +} + +func (f *agentMirrorFixture) appendText(t *testing.T, channel store.ChannelID, author store.AccountID, topic, text string) store.Message { + t.Helper() + body := text + msg, _, err := f.st.AppendMessage(f.ctx, store.Message{AuthorAccountID: author, Blocks: []store.MessageBlock{{Text: &body}}}, string(channel), store.TopicRef{Name: topic, Create: true}, "") + if err != nil { + t.Fatalf("AppendMessage(%q): %v", text, err) + } + return msg +} + +// mirror runs the arm through the forge dispatch as the fixture agent. +func (f *agentMirrorFixture) mirror(t *testing.T, svc *forgeService, key string, req *compassv1internal.MirrorMessagesRequest) *compassv1internal.ForgeCallResult { + t.Helper() + result, err := svc.ExecuteForgeCallAsAccount(f.ctx, f.agent.ID, agentMirrorSession, &compassv1internal.ForgeCallRequest{ + ClientRequestId: key, + Call: &compassv1internal.ForgeCallRequest_MirrorMessages{MirrorMessages: req}, + }) + if err != nil { + t.Fatalf("ExecuteForgeCallAsAccount(mirror_messages): %v", err) + } + return result +} + +func TestMirrorMessagesRejectionsMapToInvalidArgumentOrNotFound(t *testing.T) { + f := newAgentMirrorFixture(t) + fifty := make([]string, 51) + for i := range fifty { + fifty[i] = fmt.Sprintf("m-%02d", i) + } + for _, tc := range []struct { + name string + req *compassv1internal.MirrorMessagesRequest + code connect.Code + }{ + {"both sources", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, MessageIds: f.msgIDs}, connect.CodeInvalidArgument}, + {"neither source", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12"}, connect.CodeInvalidArgument}, + {"more than 50 ids", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", MessageIds: fifty}, connect.CodeInvalidArgument}, + {"invalid ref", &compassv1internal.MirrorMessagesRequest{Ref: "not a ref", TopicId: f.topicID}, connect.CodeInvalidArgument}, + {"ids from two channels", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", MessageIds: []string{f.msgIDs[0], f.hiddenMsg}}, connect.CodeNotFound}, + {"invisible topic", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.hiddenTop}, connect.CodeNotFound}, + {"invisible message", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", MessageIds: []string{f.hiddenMsg}}, connect.CodeNotFound}, + {"unknown topic", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: "no-such-topic"}, connect.CodeNotFound}, + {"unknown channel name", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, Channel: "no-such-channel"}, connect.CodeNotFound}, + {"invisible channel name", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, Channel: "mirror-hidden"}, connect.CodeNotFound}, + } { + t.Run(tc.name, func(t *testing.T) { + fe := f.mirror(t, f.svc, "", tc.req).GetError() + if fe == nil || fe.GetCode() != tc.code.String() { + t.Fatalf("mirror_messages error = %v, want %s", fe, tc.code) + } + }) + } + if calls := f.author.Calls(); len(calls) != 0 { + t.Fatalf("provider calls = %+v, want none for rejected calls", calls) + } + // An unknown and an invisible channel read the same, so neither leaks. + unknown := f.mirror(t, f.svc, "", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, Channel: "no-such-channel"}).GetError() + invisible := f.mirror(t, f.svc, "", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, Channel: "mirror-hidden"}).GetError() + if unknown.GetMessage() != invisible.GetMessage() { + t.Fatalf("unknown channel %q and invisible channel %q differ", unknown.GetMessage(), invisible.GetMessage()) + } +} + +func TestMirrorMessagesRetryReturnsFirstCommentAndPostsOnce(t *testing.T) { + f := newAgentMirrorFixture(t) + req := &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", MessageIds: f.msgIDs, Channel: "mirror-tool", Note: "Context for review."} + first := f.mirror(t, f.svc, "call-1", req) + if first.GetIssueComment().GetUrl() != f.author.CommentResult.URL { + t.Fatalf("first result = %v, want the posted issue comment", first) + } + second := f.mirror(t, f.svc, "call-1", req) + if second.GetIssueComment().GetUrl() != first.GetIssueComment().GetUrl() || second.GetIssueComment().GetCommentId() != first.GetIssueComment().GetCommentId() { + t.Fatalf("retry result = %v, want the first CommentRef %v", second, first) + } + calls := f.author.Calls() + if len(calls) != 1 || calls[0].Method != "CommentOnIssue" { + t.Fatalf("provider calls = %+v, want exactly one post", calls) + } + // The direct call's header names the calling session. + if !strings.Contains(calls[0].Body, "agent=mirror-tool-agent owner=mirror-tool-owner session="+agentMirrorSession) { + t.Fatalf("posted body = %q, want the caller's session in the owner header", calls[0].Body) + } + for _, want := range []string{"Context for review.", "first message", "second message", "#mirror-tool"} { + if !strings.Contains(calls[0].Body, want) { + t.Fatalf("posted body = %q, want %q", calls[0].Body, want) + } + } +} + +func TestMirrorMessagesTopicTwiceEditsOneComment(t *testing.T) { + f := newAgentMirrorFixture(t) + first := f.mirror(t, f.svc, "call-a", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, Note: "First note."}) + f.appendText(t, f.channel.ID, f.owner.ID, "mirror-thread", "third message") + second := f.mirror(t, f.svc, "call-b", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, Note: "Second note."}) + if first.GetIssueComment() == nil || second.GetIssueComment().GetCommentId() != first.GetIssueComment().GetCommentId() { + t.Fatalf("results = %v then %v, want one comment", first, second) + } + calls := f.author.Calls() + if len(calls) != 2 || calls[0].Method != "CommentOnIssue" || calls[1].Method != "EditComment" || calls[1].CommentKey != "tool-comment" { + t.Fatalf("provider calls = %+v, want one post then an edit of it", calls) + } + if !strings.Contains(calls[1].Body, "Second note.") || strings.Contains(calls[1].Body, "First note.") || !strings.Contains(calls[1].Body, "third message") { + t.Fatalf("edited body = %q, want the new note and message", calls[1].Body) + } +} + +func TestMirrorMessagesOverLimitDropsOldestWholeMessages(t *testing.T) { + texts := make([]string, 6) + for i := range texts { + texts[i] = fmt.Sprintf("message-%d %s", i, strings.Repeat("x", 120)) + } + f := newAgentMirrorFixture(t, texts...) + f.author.BodyLimitResult = mirrorOwnerHeaderAllowance + 600 + result := f.mirror(t, f.svc, "call-limit", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID}) + if result.GetIssueComment() == nil { + t.Fatalf("result = %v, want a posted comment", result) + } + body := f.author.Calls()[0].Body + if !strings.Contains(body, "Dropped ") || !strings.Contains(body, "older messages.") { + t.Fatalf("body = %q, want a dropped-messages note", body) + } + if strings.Contains(body, "message-0 ") || !strings.Contains(body, "message-5 ") { + t.Fatalf("body = %q, want the oldest dropped and the newest kept", body) + } + if len(body) > f.author.BodyLimitResult { + t.Fatalf("body has %d bytes, over the provider limit %d", len(body), f.author.BodyLimitResult) + } +} + +func TestMirrorMessagesRefusesOffManualAndUnscopedTargets(t *testing.T) { + f := newAgentMirrorFixture(t) + for _, tc := range []struct { + name, ref string + code connect.Code + }{ + {"off", "owner/off#1", connect.CodeFailedPrecondition}, + {"manual", "owner/manual#1", connect.CodeFailedPrecondition}, + {"no scope", "owner/noscope#1", connect.CodeNotFound}, + } { + t.Run(tc.name, func(t *testing.T) { + fe := f.mirror(t, f.svc, "", &compassv1internal.MirrorMessagesRequest{Ref: tc.ref, TopicId: f.topicID}).GetError() + if fe == nil || fe.GetCode() != tc.code.String() { + t.Fatalf("mirror_messages(%s) error = %v, want %s", tc.ref, fe, tc.code) + } + }) + } + if calls := f.author.Calls(); len(calls) != 0 { + t.Fatalf("provider calls = %+v, want none", calls) + } +} + +func TestMirrorMessagesChannelNameDerivesOrMustMatch(t *testing.T) { + f := newAgentMirrorFixture(t) + other, err := f.st.CreateChannel(f.ctx, f.owner.ID, store.NewChannel{Name: "mirror-other", Kind: store.ChannelKindChannel, MemberAccountIDs: []store.AccountID{f.owner.ID, f.agent.ID}}) + if err != nil { + t.Fatalf("CreateChannel(other): %v", err) + } + if fe := f.mirror(t, f.svc, "", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, Channel: other.Name}).GetError(); fe.GetCode() != connect.CodeNotFound.String() { + t.Fatalf("mismatched channel error = %v, want not_found", fe) + } + // A pull_requests row routes the post and the result to the PR arm. + if _, err := f.st.UpsertPullRequest(f.ctx, store.PullRequestRow{ + Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/auto", Number: 13}, + State: "open", CreatedAt: time.Unix(1_700_000_000, 0), UpdatedAt: time.Unix(1_700_000_000, 0), PR: []byte(`{}`), + }, nil); err != nil { + t.Fatalf("UpsertPullRequest: %v", err) + } + result := f.mirror(t, f.svc, "call-derived", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#13", MessageIds: f.msgIDs}) + if result.GetPrComment().GetUrl() != f.author.CommentResult.URL { + t.Fatalf("omitted-channel result = %v, want a PR comment", result) + } + calls := f.author.Calls() + if len(calls) != 1 || calls[0].Method != "CommentOnPullRequest" || !strings.Contains(calls[0].Body, "#mirror-tool") { + t.Fatalf("provider calls = %+v, want one PR comment naming the derived channel", calls) + } +} + +// A row left pending by a server that stopped before driving it is redriven +// by a fresh service and store handle: same body, stored session. +func TestMirrorMessagesRedriveAfterRestartUsesStoredPayloadAndSession(t *testing.T) { + f := newAgentMirrorFixture(t) + f.svc.StopMirrorLifetime() + pending := f.mirror(t, f.svc, "call-restart", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, Note: "Kept across restart."}) + if pending.GetError().GetCode() != connect.CodeUnavailable.String() || len(f.author.Calls()) != 0 { + t.Fatalf("closing-server result = %v, calls=%d; want unavailable and no post", pending, len(f.author.Calls())) + } + fresh := f.service(t, openAgentMirrorStore(t, f.dsn)) + fresh.DriveAgent(f.ctx, f.agent.ID) + calls := f.author.Calls() + if len(calls) != 1 { + t.Fatalf("provider calls after redrive = %+v, want one post", calls) + } + for _, want := range []string{"session=" + agentMirrorSession, "Kept across restart.", "first message", "second message", "#mirror-tool"} { + if !strings.Contains(calls[0].Body, want) { + t.Fatalf("redriven body = %q, want %q", calls[0].Body, want) + } + } + // The redriven body matches what the stored payload renders now. + rows, err := fresh.mirrorStoreDB.OpenForgeMirrors(f.ctx, f.agent.ID) + if err != nil || len(rows) != 0 { + t.Fatalf("open rows after redrive = (%+v, %v), want none", rows, err) + } +} + +// A redrive after the agent lost the channel fails the row without posting. +func TestMirrorMessagesRedriveFailsWhenSourceIsNoLongerVisible(t *testing.T) { + f := newAgentMirrorFixture(t) + f.svc.StopMirrorLifetime() + f.mirror(t, f.svc, "call-revoked", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID}) + if _, _, err := f.st.UpdateChannelMembers(f.ctx, f.owner.ID, f.channel.ID, []store.MemberUpdate{{AccountID: f.agent.ID, Remove: true}}, store.MemberUpdatesOptions{}); err != nil { + t.Fatalf("UpdateChannelMembers(remove agent): %v", err) + } + fresh := f.service(t, f.st) + fresh.DriveAgent(f.ctx, f.agent.ID) + if calls := f.author.Calls(); len(calls) != 0 { + t.Fatalf("provider calls = %+v, want none after the agent lost the channel", calls) + } + retry := f.mirror(t, fresh, "call-revoked", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID}) + if fe := retry.GetError(); fe == nil || fe.GetCode() == connect.CodeUnavailable.String() { + t.Fatalf("retry after failed redrive = %v, want the stored failure", retry) + } + var state int16 + rows, err := f.st.ForgeMirrorByID(f.ctx, mirrorRowIDForKey(t, f, "call-revoked")) + if err == nil { + state = rows.State + } + if state != 3 { + t.Fatalf("row state = %d (err %v), want failed", state, err) + } +} + +func mirrorRowIDForKey(t *testing.T, f *agentMirrorFixture, key string) string { + t.Helper() + row, created, err := f.st.ClaimForgeMirror(f.ctx, store.ForgeMirrorRow{ + Source: 2, SourceKey: f.topicID, Actor: f.agent.ID, Agent: f.agent.ID, ClientRequestID: key, + TargetRef: "owner/auto#12", BodyRev: 1, State: 1, + }) + if err != nil || created { + t.Fatalf("ClaimForgeMirror(lookup %q) = (created=%v, err=%v), want the existing row", key, created, err) + } + return row.ID +} diff --git a/go/server/forge_mirror_pgtest_test.go b/go/server/forge_mirror_pgtest_test.go index 266040810..3f45b99f9 100644 --- a/go/server/forge_mirror_pgtest_test.go +++ b/go/server/forge_mirror_pgtest_test.go @@ -29,7 +29,7 @@ type forgeMirrorLeaseLossPoster struct { deleteRow bool } -func (*forgeMirrorLeaseLossPoster) PostMirror(context.Context, store.AccountID, store.ForgeMirrorMode, refs.Ref, string, string, string) (forge.Comment, error) { +func (*forgeMirrorLeaseLossPoster) PostMirror(context.Context, store.AccountID, string, store.ForgeMirrorMode, refs.Ref, string, string, string) (forge.Comment, error) { return forge.Comment{}, errors.New("unexpected post during recheck") } @@ -492,7 +492,8 @@ func TestCommittedAskMirrorsAndAnswerEditsOneComment(t *testing.T) { forgeSvc.enforceScopes = true done := make(chan struct{}, 2) cm.SetMirrorer(forgeSvc, forgeMirrorSignalDriver{inner: forgeSvc, done: done}) - posted, err := cm.PostMessage(comms.WithActor(ctx, agent.ID), connect.NewRequest(&compassv1.PostMessageRequest{ + // The relay puts the verified posting session on ctx; the ask row keeps it. + posted, err := cm.PostMessage(comms.WithActor(store.WithAgentSession(ctx, "sess-ask"), agent.ID), connect.NewRequest(&compassv1.PostMessageRequest{ Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: string(channel.ID)}, Topic: &compassv1.PostMessageRequest_TopicName{TopicName: "ask-thread"}, CreateTopic: true, Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Ask{Ask: &compassv1.Ask{ @@ -514,7 +515,7 @@ func TestCommittedAskMirrorsAndAnswerEditsOneComment(t *testing.T) { t.Fatalf("MirrorAsUser(auto ask) = (%q, %v), want existing comment URL", clickURL, err) } calls := author.Calls() - if len(calls) != 1 || calls[0].Method != "CommentOnIssue" || !strings.Contains(calls[0].Body, "agent=mirror-ask-agent owner=mirror-ask-owner") || !strings.Contains(calls[0].Body, "compass-mirror:") { + if len(calls) != 1 || calls[0].Method != "CommentOnIssue" || !strings.Contains(calls[0].Body, "agent=mirror-ask-agent owner=mirror-ask-owner session=sess-ask") || !strings.Contains(calls[0].Body, "compass-mirror:") { t.Fatalf("automatic ask post calls = %+v, want one stamped comment with a marker", calls) } if !strings.Contains(calls[0].Body, "#mirror-auto-asks › ask-thread") || strings.Contains(calls[0].Body, string(channel.ID)) || strings.Contains(calls[0].Body, askID) { @@ -642,7 +643,7 @@ type blockingMirrorPoster struct { release chan struct{} } -func (p *blockingMirrorPoster) PostMirror(ctx context.Context, _ store.AccountID, _ store.ForgeMirrorMode, _ refs.Ref, _ string, _ string, _ string) (forge.Comment, error) { +func (p *blockingMirrorPoster) PostMirror(ctx context.Context, _ store.AccountID, _ string, _ store.ForgeMirrorMode, _ refs.Ref, _ string, _ string, _ string) (forge.Comment, error) { select { case p.entered <- struct{}{}: default: @@ -781,7 +782,7 @@ type driveAgentMirrorPoster struct { bodies []string } -func (p *driveAgentMirrorPoster) PostMirror(_ context.Context, _ store.AccountID, _ store.ForgeMirrorMode, _ refs.Ref, _ string, _ string, body string) (forge.Comment, error) { +func (p *driveAgentMirrorPoster) PostMirror(_ context.Context, _ store.AccountID, _ string, _ store.ForgeMirrorMode, _ refs.Ref, _ string, _ string, body string) (forge.Comment, error) { p.bodies = append(p.bodies, body) return forge.Comment{ID: 17, Key: "agent-comment", URL: "https://github.com/owner/auto/issues/12#issuecomment-17"}, nil } diff --git a/go/server/forge_test.go b/go/server/forge_test.go index 4613bc982..3c5662180 100644 --- a/go/server/forge_test.go +++ b/go/server/forge_test.go @@ -16,6 +16,7 @@ import ( "github.com/RigelBuild/compass/go/internal/board" "github.com/RigelBuild/compass/go/internal/forge" compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/mirror" "github.com/RigelBuild/compass/go/internal/refs" "github.com/RigelBuild/compass/go/internal/store" "math" @@ -1179,19 +1180,27 @@ func scopedWriteCalls() map[string]*compassv1internal.ForgeCallRequest { } // A new ForgeCallRequest arm must be either a gated write (so the rejection test -// covers it) or an explicit ungated read or caller-scoped arm. +// covers it), an explicit ungated read or caller-scoped arm, or an arm gated by +// the mirror policy (its scope refusal is a pgtest: it needs the mirror store). func TestForgeCallArmsHaveScopeClassification(t *testing.T) { ungated := map[string]bool{"get_issue": true, "list_issues": true, "get_pull_request": true, "unsubscribe": true} + mirrorGated := map[string]bool{"mirror_messages": true} writes := scopedWriteCalls() fields := (&compassv1internal.ForgeCallRequest{}).ProtoReflect().Descriptor().Oneofs().ByName("call").Fields() - if len(writes)+len(ungated) != fields.Len() { - t.Fatalf("classified arms = %d, descriptor arms = %d", len(writes)+len(ungated), fields.Len()) + if len(writes)+len(ungated)+len(mirrorGated) != fields.Len() { + t.Fatalf("classified arms = %d, descriptor arms = %d", len(writes)+len(ungated)+len(mirrorGated), fields.Len()) } for i := range fields.Len() { name := string(fields.Get(i).Name()) call, isWrite := writes[name] - if isWrite == ungated[name] { - t.Errorf("arm %q must be exactly one of gated write or ungated", name) + classes := 0 + for _, in := range []bool{isWrite, ungated[name], mirrorGated[name]} { + if in { + classes++ + } + } + if classes != 1 { + t.Errorf("arm %q must be exactly one of gated write, ungated, or mirror-gated", name) continue } if isWrite && call.ProtoReflect().WhichOneof(fields.Get(i).ContainingOneof()).Name() != fields.Get(i).Name() { @@ -1575,7 +1584,7 @@ func TestForgeMirrorPostUsesManualBodyAndLimit(t *testing.T) { st.accounts[testOwnerID] = store.Account{ID: testOwnerID, Handle: testOwnerHandle, User: &store.UserAccount{}} st.mirrorModes[fmt.Sprintf("%d|%s|%s", store.ForgeProviderGitHub, testHost, testRepo)] = store.ForgeMirrorManual - comment, err := svc.PostMirror(ctx, testOwnerID, store.ForgeMirrorManual, ref, "", "client-1", "source body") + comment, err := svc.PostMirror(ctx, testOwnerID, "", store.ForgeMirrorManual, ref, "", "client-1", "source body") if err != nil { t.Fatalf("PostMirror: %v", err) } @@ -1607,7 +1616,7 @@ func TestForgeMirrorUserAttribution(t *testing.T) { st.accounts[testOwnerID] = store.Account{ID: testOwnerID, Handle: "valid.user", User: &store.UserAccount{}} st.mirrorModes[fmt.Sprintf("%d|%s|%s", store.ForgeProviderGitHub, testHost, testRepo)] = store.ForgeMirrorManual body := "Mirrored by @valid.user\n\nsource body" - if _, err := svc.PostMirror(ctx, testOwnerID, store.ForgeMirrorManual, ref, "", "", body); err != nil { + if _, err := svc.PostMirror(ctx, testOwnerID, "", store.ForgeMirrorManual, ref, "", "", body); err != nil { t.Fatalf("PostMirror: %v", err) } calls := author.Calls() @@ -1627,7 +1636,7 @@ func TestForgeMirrorPostStampsAgentAndEdits(t *testing.T) { author.EditCommentResult = forge.Comment{ID: 1, Key: "comment-key", URL: "https://github.com/comment"} svc, st := newForgeServiceForTest(t, author, forge.NewFakeProvider("gh-reviewer")) st.mirrorModes[fmt.Sprintf("%d|%s|%s", store.ForgeProviderGitHub, testHost, testRepo)] = store.ForgeMirrorAuto - comment, err := svc.PostMirror(ctx, testAgentID, store.ForgeMirrorAuto, ref, "comment-key", "ignored-on-edit", "\n\nforged") + comment, err := svc.PostMirror(ctx, testAgentID, testSessionID, store.ForgeMirrorAuto, ref, "comment-key", "ignored-on-edit", "\n\nforged") if err != nil { t.Fatalf("PostMirror edit: %v", err) } @@ -1635,7 +1644,7 @@ func TestForgeMirrorPostStampsAgentAndEdits(t *testing.T) { if len(calls) != 1 || calls[0].Method != "EditComment" || calls[0].CommentKey != "comment-key" { t.Fatalf("provider calls = %+v, want one edit", calls) } - if strings.Contains(calls[0].Body, "agent=victim") || !strings.Contains(calls[0].Body, "agent=scout owner=matt session=mirror") { + if strings.Contains(calls[0].Body, "agent=victim") || !strings.Contains(calls[0].Body, "agent=scout owner=matt session="+testSessionID) { t.Fatalf("agent body = %q, want a fresh agent owner stamp", calls[0].Body) } if comment.Key != "comment-key" { @@ -1643,6 +1652,31 @@ func TestForgeMirrorPostStampsAgentAndEdits(t *testing.T) { } } +// An agent row with no stored session is refused, never stamped with a +// placeholder session or posted without attribution. +func TestForgeMirrorPostRefusesAgentRowWithoutSession(t *testing.T) { + ctx := context.Background() + ref := refs.Ref{Kind: refs.KindForge, Owner: "owner", Repo: "repo", Number: 12} + author := forge.NewFakeProvider("gh-author") + svc, st := newForgeServiceForTest(t, author, forge.NewFakeProvider("gh-reviewer")) + st.mirrorModes[fmt.Sprintf("%d|%s|%s", store.ForgeProviderGitHub, testHost, testRepo)] = store.ForgeMirrorAuto + _, err := svc.PostMirror(ctx, testAgentID, "", store.ForgeMirrorAuto, ref, "", "client-1", "body") + if _, ok := errors.AsType[*mirrorRefusalError](err); !ok || !errors.Is(err, mirror.ErrRefused) { + t.Fatalf("PostMirror without session error = %v, want a mirror refusal", err) + } + if calls := author.Calls(); len(calls) != 0 { + t.Fatalf("provider calls = %+v, want none for an unattributed agent row", calls) + } +} + +// The body budget reserves the widest session the owner header admits. +func TestForgeMirrorOwnerAllowanceCoversWidestSession(t *testing.T) { + widest := forge.MaxOwnerHeaderLen(strings.Repeat("a", 39)) + if mirrorOwnerHeaderAllowance != widest { + t.Fatalf("mirrorOwnerHeaderAllowance = %d, want %d for a 39-character session", mirrorOwnerHeaderAllowance, widest) + } +} + func TestForgeMirrorBodyLimitReservesOwnerHeader(t *testing.T) { ref := refs.Ref{Kind: refs.KindForge, Owner: "owner", Repo: "repo", Number: 12} author := forge.NewFakeProvider("gh-author") diff --git a/packages/compass-agent/src/cli.test.ts b/packages/compass-agent/src/cli.test.ts index 97f57e256..9094d10ae 100644 --- a/packages/compass-agent/src/cli.test.ts +++ b/packages/compass-agent/src/cli.test.ts @@ -3129,7 +3129,7 @@ describe("main wires the mounted agent-config into createAgentSession", () => { // board natives are ALWAYS merged in (RIG-1741/RIG-2672/RIG-3191) — so // customTools carries exactly those, and never a discovered MCP tool. expect(toolNames(seen[0].customTools)).toContain("agents_spawn_peer"); - expect(seen[0].customTools).toHaveLength(27); + expect(seen[0].customTools).toHaveLength(28); expect(seen[0].enableMCP).toBe(false); }); @@ -3162,7 +3162,7 @@ describe("main wires the mounted agent-config into createAgentSession", () => { // (RIG-1741/RIG-2672/RIG-3191) — so customTools is exactly the comms/ // lifecycle/forge/board natives. expect(toolNames(seen[0].customTools)).toContain("comms_post_message"); - expect(seen[0].customTools).toHaveLength(27); + expect(seen[0].customTools).toHaveLength(28); }); // ── RIG-1732 T10: COMPASS_ROLE → prompts//SYSTEM.md → customSystemPrompt ── diff --git a/packages/compass-agent/src/compassv1.ts b/packages/compass-agent/src/compassv1.ts index c5e3e2104..361b1b17c 100644 --- a/packages/compass-agent/src/compassv1.ts +++ b/packages/compass-agent/src/compassv1.ts @@ -22,8 +22,8 @@ export { type BoardCallResult, BoardCallResultSchema, // The agent-initiated forge call envelopes (internal-only AgentGateway gen). Request - // carries `call_id`, a oneof over the twelve forge arms, an optional `ForgeRef`, and a - // `client_request_id` (create arms); Result retypes to canonical Issue/PullRequest/etc + // carries `call_id`, a oneof over thirteen forge arms, an optional `ForgeRef`, and a + // `client_request_id` (create and mirror arms); Result retypes to canonical Issue/PullRequest/etc // plus an in-band `error` arm carrying `retry_after_ms`. Reused as RelayForgeCall payloads. CommentOnIssueRequestSchema, CommentOnPullRequestRequestSchema, @@ -64,6 +64,7 @@ export { ListIssuesRequestSchema, type ListIssuesResponse, ListIssuesResponseSchema, + MirrorMessagesRequestSchema, PullRequestIssueLinkSchema, type ReviewCommentInput, ReviewCommentInputSchema, diff --git a/packages/compass-agent/src/forge.test.ts b/packages/compass-agent/src/forge.test.ts index fce3e2ca8..a07e0d464 100644 --- a/packages/compass-agent/src/forge.test.ts +++ b/packages/compass-agent/src/forge.test.ts @@ -1,4 +1,4 @@ -// ForgeBroker + the twelve native forge tools (design: +// ForgeBroker + the thirteen native forge tools (design: // docs/designs/agent/compass-agent-forge-tools/design.md, T1 + T2). // Each test defends an observable contract of the agent->Runner forge call: the // exact `ForgeCallRequest` a tool `execute` puts on the wire (arm case, arm @@ -29,6 +29,7 @@ import { IssueSchema, ListIssuesResponseSchema, type MessageInitShape, + MirrorMessagesRequestSchema, PullRequestSchema, ReviewRefSchema, SubscribeForgeResponseSchema, @@ -45,6 +46,7 @@ import { getIssueParameters, getPullRequestParameters, listIssuesParameters, + mirrorMessagesParameters, submitReviewParameters, subscribeParameters, transitionIssueStateParameters, @@ -201,7 +203,7 @@ describe("ForgeBroker", () => { }); }); describe("createForgeTools", () => { - test("exposes all twelve forge tools and registers transitions as writes", () => { + test("exposes all thirteen forge tools and registers transitions as writes", () => { const tools = createForgeTools( new ForgeBroker(new FakeTransport(issueResult())), ); @@ -218,7 +220,11 @@ describe("createForgeTools", () => { "forge_transition_pull_request_state", "forge_subscribe", "forge_unsubscribe", + "forge_mirror_messages", ]); + expect( + tools.filter((t) => t.name === "forge_mirror_messages"), + ).toHaveLength(1); expect( tools.every((t) => t.label.length > 0 && t.description.length > 0), ).toBe(true); @@ -244,6 +250,7 @@ describe("createForgeTools", () => { "forge_transition_issue_state", "forge_transition_pull_request_state", "forge_subscribe", + "forge_mirror_messages", "forge_unsubscribe", ]) expect(approvalOf(w)).toBe("write"); @@ -806,6 +813,79 @@ describe("forge_submit_review", () => { }); }); +describe("forge_mirror_messages", () => { + test("sends the selected source, note, and broker idempotency key", async () => { + const transport = new FakeTransport( + commentResult("issueComment", { + url: "https://github.com/o/r/issues/12#c1", + }), + ); + const broker = new ForgeBroker(transport); + const result = await exec( + tool(broker, "forge_mirror_messages"), + "mirror-call", + { + ref: "o/r#12", + message_ids: ["m1", "m2"], + channel: "support", + note: "Please review", + }, + ); + const req = transport.requests[0]; + expect(req.call.case).toBe("mirrorMessages"); + if (req.call.case !== "mirrorMessages") throw new Error("expected arm"); + expect(req.call.value.ref).toBe("o/r#12"); + expect(req.call.value.messageIds).toEqual(["m1", "m2"]); + expect(req.call.value.topicId).toBe(""); + expect(req.call.value.channel).toBe("support"); + expect(req.call.value.note).toBe("Please review"); + expect(req.clientRequestId).toBe(broker.idempotencyKey("mirror-call")); + expect(textOf(result)).toBe( + "Mirrored messages to issue o/r#12: https://github.com/o/r/issues/12#c1", + ); + }); + + test("renders the PR result variant", async () => { + const transport = new FakeTransport( + commentResult("prComment", { + url: "https://github.com/o/r/pull/12#c1", + }), + ); + const result = await exec( + tool(new ForgeBroker(transport), "forge_mirror_messages"), + "mirror-pr-call", + { ref: "o/r#12", topic_id: "topic-1" }, + ); + const req = transport.requests[0]; + if (req.call.case !== "mirrorMessages") throw new Error("expected arm"); + expect(req.call.value.topicId).toBe("topic-1"); + expect(req.call.value.messageIds).toEqual([]); + expect(req.call.value.channel).toBe(""); + expect(textOf(result)).toBe( + "Mirrored messages to pull request o/r#12: https://github.com/o/r/pull/12#c1", + ); + }); + + test.each([ + { name: "neither source", params: { ref: "o/r#12" } }, + { + name: "both sources", + params: { ref: "o/r#12", topic_id: "topic-1", message_ids: ["m1"] }, + }, + ])("rejects $name before calling the server", async ({ params }) => { + const transport = new FakeTransport(errorResult("internal", "unused")); + await expect( + exec( + tool(new ForgeBroker(transport), "forge_mirror_messages"), + "invalid-source", + params, + ), + ).rejects.toThrow( + "forge_mirror_messages: provide exactly one of topic_id or message_ids", + ); + expect(transport.requests).toHaveLength(0); + }); +}); describe("forge transition tools", () => { test("transitions an issue and returns the updated artifact", async () => { const transport = new FakeTransport( @@ -1153,22 +1233,50 @@ describe("forge parameter schemas", () => { ).toBe(true); }); - test("every tool rejects a blank or whitespace-only repo", () => { - for (const schema of [ - getIssueParameters, - getPullRequestParameters, - listIssuesParameters, - commentOnIssueParameters, - commentOnPullRequestParameters, - ] as Type[]) { - expect(rejects(schema, {})).toBe(true); - } - expect(rejects(getIssueParameters, { repo: " ", issue_number: 1 })).toBe( - true, - ); - expect(rejects(getIssueParameters, { repo: "o/r", issue_number: 1 })).toBe( - false, - ); + test("mirror parameters reject empty, oversized, or mixed sources", () => { + expect(rejects(mirrorMessagesParameters, { ref: "o/r#12" })).toBe(true); + expect( + rejects(mirrorMessagesParameters, { + ref: "o/r#12", + message_ids: [], + }), + ).toBe(true); + expect( + rejects(mirrorMessagesParameters, { + ref: "o/r#12", + topic_id: "topic-1", + message_ids: ["m1"], + }), + ).toBe(true); + expect( + rejects(mirrorMessagesParameters, { + ref: "o/r#12", + message_ids: Array.from({ length: 51 }, (_, i) => `m${i}`), + }), + ).toBe(true); + expect( + rejects(mirrorMessagesParameters, { + ref: "o/r#12", + message_ids: ["m1"], + }), + ).toBe(false); + }); + + test("mirror wire schema exposes the request arm", () => { + expect( + create(MirrorMessagesRequestSchema, { + ref: "o/r#12", + messageIds: ["m1"], + topicId: "", + channel: "support", + note: "Review", + }), + ).toMatchObject({ + ref: "o/r#12", + messageIds: ["m1"], + channel: "support", + note: "Review", + }); }); // Message-level coverage for the SHARED `nonBlank` helper path. The diff --git a/packages/compass-agent/src/forge.ts b/packages/compass-agent/src/forge.ts index b13595274..6b2631515 100644 --- a/packages/compass-agent/src/forge.ts +++ b/packages/compass-agent/src/forge.ts @@ -1,6 +1,6 @@ -// The agent's forge surface: a thin broker over the Runner transport, plus the twelve native +// The agent's forge surface: a thin broker over the Runner transport, plus the thirteen native // tools to read and write forge artifacts — issues, PRs, comments, reviews, state -// transitions, and change-notification subscriptions (design compass-agent-forge-tools T1 + T2). +// transitions, subscriptions, and message mirroring (design compass-agent-forge-tools T1 + T2). // Mirrors comms.ts / lifecycle.ts: `AgentGateway.Forge` is a Connect unary over the // per-container Unix socket, so a result is the awaited return value — no pending map, no @@ -55,6 +55,7 @@ import { TransitionIssueStateRequestSchema, TransitionPullRequestStateRequestSchema, UnsubscribeForgeRequestSchema, + MirrorMessagesRequestSchema, } from "./compassv1"; import { relayCall } from "./relay-error"; import { attr, flat, login, ref } from "./render-guard"; @@ -161,6 +162,22 @@ export const listIssuesParameters = type({ }); /** Exported so a test can validate the wire contract the agent loop enforces. */ +export const mirrorMessagesParameters = type({ + ...forgeSelector, + ref: nonBlank("Forge issue or pull request reference, such as RIG-12 or owner/repo#12"), + "message_ids?": type("string[]") + .narrow((ids, ctx) => + (ids.length >= 1 && ids.length <= 50) || + ctx.mustBe("between 1 and 50 message ids"), + ) + .describe("One to fifty messages from one channel; mutually exclusive with topic_id"), + "topic_id?": nonBlank("Topic id; mutually exclusive with message_ids"), + "channel?": nonBlank("Visible source channel name; use the channel name, not its id"), + "note?": type("string").describe("Optional note shown above the mirrored messages"), +}).narrow((v, ctx) => + (v.topic_id !== undefined) !== (v.message_ids !== undefined) || + ctx.mustBe("provide exactly one of topic_id or message_ids"), +); export const commentOnIssueParameters = type({ ...forgeSelector, repo: nonBlank(REPO_DESC), @@ -587,7 +604,7 @@ function renderSubscribed( } /** - * The native forge tool set. Twelve tools, one per `ForgeCallRequest` arm. + * The native forge tool set. Thirteen tools, including the mirror arm. * * Wired into the container entrypoint by `cli.ts main()`: merged into the * session's `customTools` and registered as `#withNatives` natives. This @@ -828,6 +845,53 @@ export function createForgeTools(broker: ForgeBroker): AgentTool[] { }; }, }; + const mirrorMessages: AgentTool = { + name: "forge_mirror_messages", + label: "Mirror messages to forge", + approval: "write", + description: `Mirror one visible topic or a selected message set to an AUTO-enabled forge issue or pull request. ${SELECTOR_RULE} The source channel must be visible to you; a supplied channel is its visible channel name. The server stamps your owner attribution. ${SCOPE_DISCIPLINE}`, + parameters: mirrorMessagesParameters, + execute: async (toolCallId, params) => { + const hasTopic = params.topic_id !== undefined; + const hasMessages = params.message_ids !== undefined; + if (hasTopic === hasMessages) + throw new Error( + "forge_mirror_messages: provide exactly one of topic_id or message_ids", + ); + const result = await broker.call( + create(ForgeCallRequestSchema, { + callId: toolCallId, + call: { + case: "mirrorMessages", + value: create(MirrorMessagesRequestSchema, { + ref: params.ref, + messageIds: params.message_ids ?? [], + topicId: params.topic_id ?? "", + channel: params.channel ?? "", + note: params.note ?? "", + }), + }, + forge: forgeRef(params), + clientRequestId: broker.idempotencyKey(toolCallId), + }), + ); + if (result.result.case !== "issueComment" && result.result.case !== "prComment") + throw forgeFailure( + result, + "forge_mirror_messages", + "issueComment or prComment", + ); + const target = result.result.case === "prComment" ? "pull request" : "issue"; + return { + content: [ + { + type: "text", + text: `Mirrored messages to ${target} ${ref(params.ref)}: ${ref(result.result.value.url)}`, + }, + ], + }; + }, + }; const createIssue: AgentTool = { name: "forge_create_issue", @@ -1082,5 +1146,6 @@ export function createForgeTools(broker: ForgeBroker): AgentTool[] { transitionPullRequestState, subscribe, unsubscribe, + mirrorMessages, ]; } 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 351d1b13f..dbca74a88 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 @@ -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("Ch5jb21wYXNzL3YxL2FnZW50X2dhdGV3YXkucHJvdG8SCmNvbXBhc3MudjEi5AUKEENvbW1zQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRIuCgRwb3N0GAIgASgLMh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3RIABIvCgRsaXN0GAMgASgLMh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0SAASLgoGcm9zdGVyGAQgASgLMhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0SAASNwoKc2V0X3N0YXR1cxgFIAEoCzIhLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXF1ZXN0SAASMwoDcGluGAYgASgLMiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3RIABI6Cg5jcmVhdGVfY2hhbm5lbBgHIAEoCzIgLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3RIABJBCg51cGRhdGVfbWVtYmVycxgIIAEoCzInLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0SAASRQoUY3JlYXRlX2NoYW5uZWxfZ3JvdXAYCSABKAsyJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3RIABIsCgdvcGVuX2RtGAsgASgLMhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0SAASNAoLbGlzdF90b3BpY3MYDCABKAsyHS5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXF1ZXN0SAASOwoMdXBkYXRlX2JvYXJkGA0gASgLMiMuY29tcGFzcy52MS5VcGRhdGVBZ2VudEJvYXJkUmVxdWVzdEgAEjQKBmJvYXJkcxgOIAEoCzIiLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVxdWVzdEgAEhsKE3RyaWdnZXJfdHJhY2VwYXJlbnQYCiABKAlCBgoEY2FsbCKBBgoPQ29tbXNDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSLwoEcG9zdBgCIAEoCzIfLmNvbXBhc3MudjEuUG9zdE1lc3NhZ2VSZXNwb25zZUgAEjAKBGxpc3QYAyABKAsyIC5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1Jlc3BvbnNlSAASKwoFZXJyb3IYBCABKAsyGi5jb21wYXNzLnYxLkNvbW1zQ2FsbEVycm9ySAASLwoGcm9zdGVyGAUgASgLMh0uY29tcGFzcy52MS5HZXRSb3N0ZXJSZXNwb25zZUgAEjgKCnNldF9zdGF0dXMYBiABKAsyIi5jb21wYXNzLnYxLlNldEFnZW50U3RhdHVzUmVzcG9uc2VIABI0CgNwaW4YByABKAsyJS5jb21wYXNzLnYxLlVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2VIABI7Cg5jcmVhdGVfY2hhbm5lbBgIIAEoCzIhLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlSAASQgoOdXBkYXRlX21lbWJlcnMYCSABKAsyKC5jb21wYXNzLnYxLlVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2VIABJGChRjcmVhdGVfY2hhbm5lbF9ncm91cBgKIAEoCzImLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbEdyb3VwUmVzcG9uc2VIABItCgdvcGVuX2RtGAsgASgLMhouY29tcGFzcy52MS5PcGVuRE1SZXNwb25zZUgAEjUKC2xpc3RfdG9waWNzGAwgASgLMh4uY29tcGFzcy52MS5MaXN0VG9waWNzUmVzcG9uc2VIABI8Cgx1cGRhdGVfYm9hcmQYDSABKAsyJC5jb21wYXNzLnYxLlVwZGF0ZUFnZW50Qm9hcmRSZXNwb25zZUgAEjUKBmJvYXJkcxgOIAEoCzIjLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVzcG9uc2VIAEIICgZyZXN1bHQiLwoOQ29tbXNDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIikKFVNldEFnZW50U3RhdHVzUmVxdWVzdBIQCghhY3Rpdml0eRgBIAEoCSIYChZTZXRBZ2VudFN0YXR1c1Jlc3BvbnNlIpEBChRMaWZlY3ljbGVDYWxsUmVxdWVzdBIPCgdjYWxsX2lkGAEgASgJEi0KBXNwYXduGAIgASgLMhwuY29tcGFzcy52MS5TcGF3blBlZXJSZXF1ZXN0SAASMQoHZGVzcGF3bhgDIAEoCzIeLmNvbXBhc3MudjEuRGVzcGF3blBlZXJSZXF1ZXN0SABCBgoEY2FsbCKIAQoQU3Bhd25QZWVyUmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAQgASgJEgwKBHJvbGUYBSABKAkSDwoHcGVyc29uYRgGIAEoCUoECAMQBFIOaW5pdGlhbF9wcm9tcHQicgoRU3Bhd25QZWVyUmVzcG9uc2USGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIWCg5jb250YWluZXJfbmFtZRgCIAEoCRISCgpzZXNzaW9uX2lkGAMgASgJEhcKD2RtX2NoYW5uZWxfbmFtZRgEIAEoCSIqChJEZXNwYXduUGVlclJlcXVlc3QSFAoMYWdlbnRfaGFuZGxlGAEgASgJIhUKE0Rlc3Bhd25QZWVyUmVzcG9uc2UixQEKE0xpZmVjeWNsZUNhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIuCgVzcGF3bhgCIAEoCzIdLmNvbXBhc3MudjEuU3Bhd25QZWVyUmVzcG9uc2VIABIyCgdkZXNwYXduGAMgASgLMh8uY29tcGFzcy52MS5EZXNwYXduUGVlclJlc3BvbnNlSAASLwoFZXJyb3IYBCABKAsyHi5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxFcnJvckgAQggKBnJlc3VsdCIzChJMaWZlY3ljbGVDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIusGChBGb3JnZUNhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSNgoMY3JlYXRlX2lzc3VlGAIgASgLMh4uY29tcGFzcy52MS5DcmVhdGVJc3N1ZVJlcXVlc3RIABI9ChBjb21tZW50X29uX2lzc3VlGAMgASgLMiEuY29tcGFzcy52MS5Db21tZW50T25Jc3N1ZVJlcXVlc3RIABIwCglnZXRfaXNzdWUYBCABKAsyGy5jb21wYXNzLnYxLkdldElzc3VlUmVxdWVzdEgAEjQKC2xpc3RfaXNzdWVzGAUgASgLMh0uY29tcGFzcy52MS5MaXN0SXNzdWVzUmVxdWVzdEgAEkMKE2NyZWF0ZV9wdWxsX3JlcXVlc3QYBiABKAsyJC5jb21wYXNzLnYxLkNyZWF0ZVB1bGxSZXF1ZXN0UmVxdWVzdEgAEkoKF2NvbW1lbnRfb25fcHVsbF9yZXF1ZXN0GAcgASgLMicuY29tcGFzcy52MS5Db21tZW50T25QdWxsUmVxdWVzdFJlcXVlc3RIABI9ChBnZXRfcHVsbF9yZXF1ZXN0GAggASgLMiEuY29tcGFzcy52MS5HZXRQdWxsUmVxdWVzdFJlcXVlc3RIABI2CglzdWJzY3JpYmUYCSABKAsyIS5jb21wYXNzLnYxLlN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjoKC3Vuc3Vic2NyaWJlGAogASgLMiMuY29tcGFzcy52MS5VbnN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjgKDXN1Ym1pdF9yZXZpZXcYCyABKAsyHy5jb21wYXNzLnYxLlN1Ym1pdFJldmlld1JlcXVlc3RIABJJChZ0cmFuc2l0aW9uX2lzc3VlX3N0YXRlGA4gASgLMicuY29tcGFzcy52MS5UcmFuc2l0aW9uSXNzdWVTdGF0ZVJlcXVlc3RIABJWCh10cmFuc2l0aW9uX3B1bGxfcmVxdWVzdF9zdGF0ZRgPIAEoCzItLmNvbXBhc3MudjEuVHJhbnNpdGlvblB1bGxSZXF1ZXN0U3RhdGVSZXF1ZXN0SAASIwoFZm9yZ2UYDCABKAsyFC5jb21wYXNzLnYxLkZvcmdlUmVmEhkKEWNsaWVudF9yZXF1ZXN0X2lkGA0gASgJQgYKBGNhbGwi4AMKD0ZvcmdlQ2FsbFJlc3VsdBIPCgdjYWxsX2lkGAEgASgJEiIKBWlzc3VlGAIgASgLMhEuY29tcGFzcy52MS5Jc3N1ZUgAEi8KDWlzc3VlX2NvbW1lbnQYAyABKAsyFi5jb21wYXNzLnYxLkNvbW1lbnRSZWZIABIwCgZpc3N1ZXMYBCABKAsyHi5jb21wYXNzLnYxLkxpc3RJc3N1ZXNSZXNwb25zZUgAEi8KDHB1bGxfcmVxdWVzdBgFIAEoCzIXLmNvbXBhc3MudjEuUHVsbFJlcXVlc3RIABIsCgpwcl9jb21tZW50GAYgASgLMhYuY29tcGFzcy52MS5Db21tZW50UmVmSAASOAoKc3Vic2NyaWJlZBgHIAEoCzIiLmNvbXBhc3MudjEuU3Vic2NyaWJlRm9yZ2VSZXNwb25zZUgAEjwKDHVuc3Vic2NyaWJlZBgIIAEoCzIkLmNvbXBhc3MudjEuVW5zdWJzY3JpYmVGb3JnZVJlc3BvbnNlSAASKwoFZXJyb3IYCSABKAsyGi5jb21wYXNzLnYxLkZvcmdlQ2FsbEVycm9ySAASJwoGcmV2aWV3GAogASgLMhUuY29tcGFzcy52MS5SZXZpZXdSZWZIAEIICgZyZXN1bHQiRwoORm9yZ2VDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJEhYKDnJldHJ5X2FmdGVyX21zGAMgASgNIk8KEkNyZWF0ZUlzc3VlUmVxdWVzdBIMCgRyZXBvGAEgASgJEg0KBXRpdGxlGAIgASgJEgwKBGJvZHkYAyABKAkSDgoGbGFiZWxzGAQgAygJIkkKFUNvbW1lbnRPbklzc3VlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhQKDGlzc3VlX251bWJlchgCIAEoBBIMCgRib2R5GAMgASgJIjUKD0dldElzc3VlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhQKDGlzc3VlX251bWJlchgCIAEoBCJPChFMaXN0SXNzdWVzUmVxdWVzdBIMCgRyZXBvGAEgASgJEg0KBXN0YXRlGAIgASgJEg4KBmxhYmVscxgDIAMoCRINCgVsaW1pdBgEIAEoDSI3ChJMaXN0SXNzdWVzUmVzcG9uc2USIQoGaXNzdWVzGAEgAygLMhEuY29tcGFzcy52MS5Jc3N1ZSKpAQoYQ3JlYXRlUHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFdGl0bGUYAiABKAkSDAoEYm9keRgDIAEoCRIQCghoZWFkX3JlZhgEIAEoCRIQCghiYXNlX3JlZhgFIAEoCRINCgVkcmFmdBgGIAEoCBIvCgVpc3N1ZRgHIAEoCzIgLmNvbXBhc3MudjEuUHVsbFJlcXVlc3RJc3N1ZUxpbmsiWQoUUHVsbFJlcXVlc3RJc3N1ZUxpbmsSIwoFZm9yZ2UYASABKAsyFC5jb21wYXNzLnYxLkZvcmdlUmVmEgwKBHJlcG8YAiABKAkSDgoGbnVtYmVyGAMgASgEIkwKG0NvbW1lbnRPblB1bGxSZXF1ZXN0UmVxdWVzdBIMCgRyZXBvGAEgASgJEhEKCXByX251bWJlchgCIAEoBBIMCgRib2R5GAMgASgJIjgKFUdldFB1bGxSZXF1ZXN0UmVxdWVzdBIMCgRyZXBvGAEgASgJEhEKCXByX251bWJlchgCIAEoBCKHAQoTU3VibWl0UmV2aWV3UmVxdWVzdBIMCgRyZXBvGAEgASgJEhEKCXByX251bWJlchgCIAEoBBIPCgd2ZXJkaWN0GAMgASgJEgwKBGJvZHkYBCABKAkSMAoIY29tbWVudHMYBSADKAsyHi5jb21wYXNzLnYxLlJldmlld0NvbW1lbnRJbnB1dCJMChJSZXZpZXdDb21tZW50SW5wdXQSDAoEcGF0aBgBIAEoCRIMCgRsaW5lGAIgASgNEgwKBHNpZGUYAyABKAkSDAoEYm9keRgEIAEoCSJ+ChtUcmFuc2l0aW9uSXNzdWVTdGF0ZVJlcXVlc3QSDAoEcmVwbxgBIAEoCRIUCgxpc3N1ZV9udW1iZXIYAiABKAQSDQoFc3RhdGUYAyABKAkSFAoMY2xvc2VfcmVhc29uGAQgASgJEhYKDndvcmtmbG93X3N0YXRlGAUgASgJIlMKIVRyYW5zaXRpb25QdWxsUmVxdWVzdFN0YXRlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhEKCXByX251bWJlchgCIAEoBBINCgVzdGF0ZRgDIAEoCSKmAQoVU3Vic2NyaWJlRm9yZ2VSZXF1ZXN0EgwKBHJlcG8YASABKAkSKwoEa2luZBgCIAEoDjIdLmNvbXBhc3MudjEuRm9yZ2VBcnRpZmFjdEtpbmQSDgoGbnVtYmVyGAMgASgEEjEKBXNjb3BlGAQgASgOMiIuY29tcGFzcy52MS5Gb3JnZVN1YnNjcmlwdGlvblNjb3BlEg8KB3Byb2plY3QYBSABKAkiMQoWU3Vic2NyaWJlRm9yZ2VSZXNwb25zZRIXCg9zdWJzY3JpcHRpb25faWQYASABKAkiMgoXVW5zdWJzY3JpYmVGb3JnZVJlcXVlc3QSFwoPc3Vic2NyaXB0aW9uX2lkGAEgASgJIhoKGFVuc3Vic2NyaWJlRm9yZ2VSZXNwb25zZSJoChBCb2FyZENhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSOwoPc2V0X2lzc3VlX3N0YXRlGAIgASgLMiAuY29tcGFzcy52MS5TZXRJc3N1ZVN0YXRlUmVxdWVzdEgAQgYKBGNhbGwiTwoUU2V0SXNzdWVTdGF0ZVJlcXVlc3QSEAoIaXNzdWVfaWQYASABKAkSJQoFc3RhdGUYAiABKA4yFi5jb21wYXNzLnYxLklzc3VlU3RhdGUiOQoVU2V0SXNzdWVTdGF0ZVJlc3BvbnNlEiAKBWlzc3VlGAEgASgLMhEuY29tcGFzcy52MS5Jc3N1ZSKXAQoPQm9hcmRDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSPAoPc2V0X2lzc3VlX3N0YXRlGAIgASgLMiEuY29tcGFzcy52MS5TZXRJc3N1ZVN0YXRlUmVzcG9uc2VIABIrCgVlcnJvchgDIAEoCzIaLmNvbXBhc3MudjEuQm9hcmRDYWxsRXJyb3JIAEIICgZyZXN1bHQiLwoOQm9hcmRDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjwKE1B1Ymxpc2hGcmFtZVJlcXVlc3QSJQoFZnJhbWUYASABKAsyFi5jb21wYXNzLnYxLkFnZW50RnJhbWUiFgoUUHVibGlzaEZyYW1lUmVzcG9uc2UiXgocUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVxdWVzdBIlCgVmcmFtZRgBIAEoCzIWLmNvbXBhc3MudjEuQWdlbnRGcmFtZRIXCg9pZGVtcG90ZW5jeV9rZXkYAiABKAkiHwodUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVzcG9uc2UiGQoXQ29udHJvbFN1YnNjcmliZVJlcXVlc3QiNQoVUHV0U2Vzc2lvbkJsb2JSZXF1ZXN0Eg4KBnNoYTI1NhgBIAEoCRIMCgRkYXRhGAIgASgMIhgKFlB1dFNlc3Npb25CbG9iUmVzcG9uc2UqkQEKFkZvcmdlU3Vic2NyaXB0aW9uU2NvcGUSKAokRk9SR0VfU1VCU0NSSVBUSU9OX1NDT1BFX1VOU1BFQ0lGSUVEEAASJQohRk9SR0VfU1VCU0NSSVBUSU9OX1NDT1BFX0FSVElGQUNUEAESJgoiRk9SR0VfU1VCU0NSSVBUSU9OX1NDT1BFX0NPTlRBSU5FUhACMo0FCgxBZ2VudEdhdGV3YXkSQgoFQ29tbXMSHC5jb21wYXNzLnYxLkNvbW1zQ2FsbFJlcXVlc3QaGy5jb21wYXNzLnYxLkNvbW1zQ2FsbFJlc3VsdBJOCglMaWZlY3ljbGUSIC5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxSZXF1ZXN0Gh8uY29tcGFzcy52MS5MaWZlY3ljbGVDYWxsUmVzdWx0Ek4KB1B1Ymxpc2gSHy5jb21wYXNzLnYxLlB1Ymxpc2hGcmFtZVJlcXVlc3QaIC5jb21wYXNzLnYxLlB1Ymxpc2hGcmFtZVJlc3BvbnNlKAESbAoVUG9zdENvbnZlcnNhdGlvbkZyYW1lEiguY29tcGFzcy52MS5Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXF1ZXN0GikuY29tcGFzcy52MS5Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXNwb25zZRJKCgdDb250cm9sEiMuY29tcGFzcy52MS5Db250cm9sU3Vic2NyaWJlUmVxdWVzdBoYLmNvbXBhc3MudjEuQWdlbnRDb250cm9sMAESQgoFRm9yZ2USHC5jb21wYXNzLnYxLkZvcmdlQ2FsbFJlcXVlc3QaGy5jb21wYXNzLnYxLkZvcmdlQ2FsbFJlc3VsdBJCCgVCb2FyZBIcLmNvbXBhc3MudjEuQm9hcmRDYWxsUmVxdWVzdBobLmNvbXBhc3MudjEuQm9hcmRDYWxsUmVzdWx0ElcKDlB1dFNlc3Npb25CbG9iEiEuY29tcGFzcy52MS5QdXRTZXNzaW9uQmxvYlJlcXVlc3QaIi5jb21wYXNzLnYxLlB1dFNlc3Npb25CbG9iUmVzcG9uc2ViBnByb3RvMw", [file_compass_v1_comms, file_compass_v1_agent, file_compass_v1_compass, file_compass_v1_forge]); + fileDesc("Ch5jb21wYXNzL3YxL2FnZW50X2dhdGV3YXkucHJvdG8SCmNvbXBhc3MudjEi5AUKEENvbW1zQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRIuCgRwb3N0GAIgASgLMh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3RIABIvCgRsaXN0GAMgASgLMh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0SAASLgoGcm9zdGVyGAQgASgLMhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0SAASNwoKc2V0X3N0YXR1cxgFIAEoCzIhLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXF1ZXN0SAASMwoDcGluGAYgASgLMiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3RIABI6Cg5jcmVhdGVfY2hhbm5lbBgHIAEoCzIgLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3RIABJBCg51cGRhdGVfbWVtYmVycxgIIAEoCzInLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0SAASRQoUY3JlYXRlX2NoYW5uZWxfZ3JvdXAYCSABKAsyJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3RIABIsCgdvcGVuX2RtGAsgASgLMhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0SAASNAoLbGlzdF90b3BpY3MYDCABKAsyHS5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXF1ZXN0SAASOwoMdXBkYXRlX2JvYXJkGA0gASgLMiMuY29tcGFzcy52MS5VcGRhdGVBZ2VudEJvYXJkUmVxdWVzdEgAEjQKBmJvYXJkcxgOIAEoCzIiLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVxdWVzdEgAEhsKE3RyaWdnZXJfdHJhY2VwYXJlbnQYCiABKAlCBgoEY2FsbCKBBgoPQ29tbXNDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSLwoEcG9zdBgCIAEoCzIfLmNvbXBhc3MudjEuUG9zdE1lc3NhZ2VSZXNwb25zZUgAEjAKBGxpc3QYAyABKAsyIC5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1Jlc3BvbnNlSAASKwoFZXJyb3IYBCABKAsyGi5jb21wYXNzLnYxLkNvbW1zQ2FsbEVycm9ySAASLwoGcm9zdGVyGAUgASgLMh0uY29tcGFzcy52MS5HZXRSb3N0ZXJSZXNwb25zZUgAEjgKCnNldF9zdGF0dXMYBiABKAsyIi5jb21wYXNzLnYxLlNldEFnZW50U3RhdHVzUmVzcG9uc2VIABI0CgNwaW4YByABKAsyJS5jb21wYXNzLnYxLlVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2VIABI7Cg5jcmVhdGVfY2hhbm5lbBgIIAEoCzIhLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlSAASQgoOdXBkYXRlX21lbWJlcnMYCSABKAsyKC5jb21wYXNzLnYxLlVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2VIABJGChRjcmVhdGVfY2hhbm5lbF9ncm91cBgKIAEoCzImLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbEdyb3VwUmVzcG9uc2VIABItCgdvcGVuX2RtGAsgASgLMhouY29tcGFzcy52MS5PcGVuRE1SZXNwb25zZUgAEjUKC2xpc3RfdG9waWNzGAwgASgLMh4uY29tcGFzcy52MS5MaXN0VG9waWNzUmVzcG9uc2VIABI8Cgx1cGRhdGVfYm9hcmQYDSABKAsyJC5jb21wYXNzLnYxLlVwZGF0ZUFnZW50Qm9hcmRSZXNwb25zZUgAEjUKBmJvYXJkcxgOIAEoCzIjLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVzcG9uc2VIAEIICgZyZXN1bHQiLwoOQ29tbXNDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIikKFVNldEFnZW50U3RhdHVzUmVxdWVzdBIQCghhY3Rpdml0eRgBIAEoCSIYChZTZXRBZ2VudFN0YXR1c1Jlc3BvbnNlIpEBChRMaWZlY3ljbGVDYWxsUmVxdWVzdBIPCgdjYWxsX2lkGAEgASgJEi0KBXNwYXduGAIgASgLMhwuY29tcGFzcy52MS5TcGF3blBlZXJSZXF1ZXN0SAASMQoHZGVzcGF3bhgDIAEoCzIeLmNvbXBhc3MudjEuRGVzcGF3blBlZXJSZXF1ZXN0SABCBgoEY2FsbCKIAQoQU3Bhd25QZWVyUmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAQgASgJEgwKBHJvbGUYBSABKAkSDwoHcGVyc29uYRgGIAEoCUoECAMQBFIOaW5pdGlhbF9wcm9tcHQicgoRU3Bhd25QZWVyUmVzcG9uc2USGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIWCg5jb250YWluZXJfbmFtZRgCIAEoCRISCgpzZXNzaW9uX2lkGAMgASgJEhcKD2RtX2NoYW5uZWxfbmFtZRgEIAEoCSIqChJEZXNwYXduUGVlclJlcXVlc3QSFAoMYWdlbnRfaGFuZGxlGAEgASgJIhUKE0Rlc3Bhd25QZWVyUmVzcG9uc2UixQEKE0xpZmVjeWNsZUNhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIuCgVzcGF3bhgCIAEoCzIdLmNvbXBhc3MudjEuU3Bhd25QZWVyUmVzcG9uc2VIABIyCgdkZXNwYXduGAMgASgLMh8uY29tcGFzcy52MS5EZXNwYXduUGVlclJlc3BvbnNlSAASLwoFZXJyb3IYBCABKAsyHi5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxFcnJvckgAQggKBnJlc3VsdCIzChJMaWZlY3ljbGVDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIqkHChBGb3JnZUNhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSNgoMY3JlYXRlX2lzc3VlGAIgASgLMh4uY29tcGFzcy52MS5DcmVhdGVJc3N1ZVJlcXVlc3RIABI9ChBjb21tZW50X29uX2lzc3VlGAMgASgLMiEuY29tcGFzcy52MS5Db21tZW50T25Jc3N1ZVJlcXVlc3RIABIwCglnZXRfaXNzdWUYBCABKAsyGy5jb21wYXNzLnYxLkdldElzc3VlUmVxdWVzdEgAEjQKC2xpc3RfaXNzdWVzGAUgASgLMh0uY29tcGFzcy52MS5MaXN0SXNzdWVzUmVxdWVzdEgAEkMKE2NyZWF0ZV9wdWxsX3JlcXVlc3QYBiABKAsyJC5jb21wYXNzLnYxLkNyZWF0ZVB1bGxSZXF1ZXN0UmVxdWVzdEgAEkoKF2NvbW1lbnRfb25fcHVsbF9yZXF1ZXN0GAcgASgLMicuY29tcGFzcy52MS5Db21tZW50T25QdWxsUmVxdWVzdFJlcXVlc3RIABI9ChBnZXRfcHVsbF9yZXF1ZXN0GAggASgLMiEuY29tcGFzcy52MS5HZXRQdWxsUmVxdWVzdFJlcXVlc3RIABI2CglzdWJzY3JpYmUYCSABKAsyIS5jb21wYXNzLnYxLlN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjoKC3Vuc3Vic2NyaWJlGAogASgLMiMuY29tcGFzcy52MS5VbnN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjgKDXN1Ym1pdF9yZXZpZXcYCyABKAsyHy5jb21wYXNzLnYxLlN1Ym1pdFJldmlld1JlcXVlc3RIABI8Cg9taXJyb3JfbWVzc2FnZXMYECABKAsyIS5jb21wYXNzLnYxLk1pcnJvck1lc3NhZ2VzUmVxdWVzdEgAEkkKFnRyYW5zaXRpb25faXNzdWVfc3RhdGUYDiABKAsyJy5jb21wYXNzLnYxLlRyYW5zaXRpb25Jc3N1ZVN0YXRlUmVxdWVzdEgAElYKHXRyYW5zaXRpb25fcHVsbF9yZXF1ZXN0X3N0YXRlGA8gASgLMi0uY29tcGFzcy52MS5UcmFuc2l0aW9uUHVsbFJlcXVlc3RTdGF0ZVJlcXVlc3RIABIjCgVmb3JnZRgMIAEoCzIULmNvbXBhc3MudjEuRm9yZ2VSZWYSGQoRY2xpZW50X3JlcXVlc3RfaWQYDSABKAlCBgoEY2FsbCJqChVNaXJyb3JNZXNzYWdlc1JlcXVlc3QSCwoDcmVmGAEgASgJEhMKC21lc3NhZ2VfaWRzGAIgAygJEhAKCHRvcGljX2lkGAMgASgJEg8KB2NoYW5uZWwYBCABKAkSDAoEbm90ZRgFIAEoCSLgAwoPRm9yZ2VDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSIgoFaXNzdWUYAiABKAsyES5jb21wYXNzLnYxLklzc3VlSAASLwoNaXNzdWVfY29tbWVudBgDIAEoCzIWLmNvbXBhc3MudjEuQ29tbWVudFJlZkgAEjAKBmlzc3VlcxgEIAEoCzIeLmNvbXBhc3MudjEuTGlzdElzc3Vlc1Jlc3BvbnNlSAASLwoMcHVsbF9yZXF1ZXN0GAUgASgLMhcuY29tcGFzcy52MS5QdWxsUmVxdWVzdEgAEiwKCnByX2NvbW1lbnQYBiABKAsyFi5jb21wYXNzLnYxLkNvbW1lbnRSZWZIABI4CgpzdWJzY3JpYmVkGAcgASgLMiIuY29tcGFzcy52MS5TdWJzY3JpYmVGb3JnZVJlc3BvbnNlSAASPAoMdW5zdWJzY3JpYmVkGAggASgLMiQuY29tcGFzcy52MS5VbnN1YnNjcmliZUZvcmdlUmVzcG9uc2VIABIrCgVlcnJvchgJIAEoCzIaLmNvbXBhc3MudjEuRm9yZ2VDYWxsRXJyb3JIABInCgZyZXZpZXcYCiABKAsyFS5jb21wYXNzLnYxLlJldmlld1JlZkgAQggKBnJlc3VsdCJHCg5Gb3JnZUNhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkSFgoOcmV0cnlfYWZ0ZXJfbXMYAyABKA0iTwoSQ3JlYXRlSXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFdGl0bGUYAiABKAkSDAoEYm9keRgDIAEoCRIOCgZsYWJlbHMYBCADKAkiSQoVQ29tbWVudE9uSXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSFAoMaXNzdWVfbnVtYmVyGAIgASgEEgwKBGJvZHkYAyABKAkiNQoPR2V0SXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSFAoMaXNzdWVfbnVtYmVyGAIgASgEIk8KEUxpc3RJc3N1ZXNSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFc3RhdGUYAiABKAkSDgoGbGFiZWxzGAMgAygJEg0KBWxpbWl0GAQgASgNIjcKEkxpc3RJc3N1ZXNSZXNwb25zZRIhCgZpc3N1ZXMYASADKAsyES5jb21wYXNzLnYxLklzc3VlIqkBChhDcmVhdGVQdWxsUmVxdWVzdFJlcXVlc3QSDAoEcmVwbxgBIAEoCRINCgV0aXRsZRgCIAEoCRIMCgRib2R5GAMgASgJEhAKCGhlYWRfcmVmGAQgASgJEhAKCGJhc2VfcmVmGAUgASgJEg0KBWRyYWZ0GAYgASgIEi8KBWlzc3VlGAcgASgLMiAuY29tcGFzcy52MS5QdWxsUmVxdWVzdElzc3VlTGluayJZChRQdWxsUmVxdWVzdElzc3VlTGluaxIjCgVmb3JnZRgBIAEoCzIULmNvbXBhc3MudjEuRm9yZ2VSZWYSDAoEcmVwbxgCIAEoCRIOCgZudW1iZXIYAyABKAQiTAobQ29tbWVudE9uUHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSEQoJcHJfbnVtYmVyGAIgASgEEgwKBGJvZHkYAyABKAkiOAoVR2V0UHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSEQoJcHJfbnVtYmVyGAIgASgEIocBChNTdWJtaXRSZXZpZXdSZXF1ZXN0EgwKBHJlcG8YASABKAkSEQoJcHJfbnVtYmVyGAIgASgEEg8KB3ZlcmRpY3QYAyABKAkSDAoEYm9keRgEIAEoCRIwCghjb21tZW50cxgFIAMoCzIeLmNvbXBhc3MudjEuUmV2aWV3Q29tbWVudElucHV0IkwKElJldmlld0NvbW1lbnRJbnB1dBIMCgRwYXRoGAEgASgJEgwKBGxpbmUYAiABKA0SDAoEc2lkZRgDIAEoCRIMCgRib2R5GAQgASgJIn4KG1RyYW5zaXRpb25Jc3N1ZVN0YXRlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhQKDGlzc3VlX251bWJlchgCIAEoBBINCgVzdGF0ZRgDIAEoCRIUCgxjbG9zZV9yZWFzb24YBCABKAkSFgoOd29ya2Zsb3dfc3RhdGUYBSABKAkiUwohVHJhbnNpdGlvblB1bGxSZXF1ZXN0U3RhdGVSZXF1ZXN0EgwKBHJlcG8YASABKAkSEQoJcHJfbnVtYmVyGAIgASgEEg0KBXN0YXRlGAMgASgJIqYBChVTdWJzY3JpYmVGb3JnZVJlcXVlc3QSDAoEcmVwbxgBIAEoCRIrCgRraW5kGAIgASgOMh0uY29tcGFzcy52MS5Gb3JnZUFydGlmYWN0S2luZBIOCgZudW1iZXIYAyABKAQSMQoFc2NvcGUYBCABKA4yIi5jb21wYXNzLnYxLkZvcmdlU3Vic2NyaXB0aW9uU2NvcGUSDwoHcHJvamVjdBgFIAEoCSIxChZTdWJzY3JpYmVGb3JnZVJlc3BvbnNlEhcKD3N1YnNjcmlwdGlvbl9pZBgBIAEoCSIyChdVbnN1YnNjcmliZUZvcmdlUmVxdWVzdBIXCg9zdWJzY3JpcHRpb25faWQYASABKAkiGgoYVW5zdWJzY3JpYmVGb3JnZVJlc3BvbnNlImgKEEJvYXJkQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRI7Cg9zZXRfaXNzdWVfc3RhdGUYAiABKAsyIC5jb21wYXNzLnYxLlNldElzc3VlU3RhdGVSZXF1ZXN0SABCBgoEY2FsbCJPChRTZXRJc3N1ZVN0YXRlUmVxdWVzdBIQCghpc3N1ZV9pZBgBIAEoCRIlCgVzdGF0ZRgCIAEoDjIWLmNvbXBhc3MudjEuSXNzdWVTdGF0ZSI5ChVTZXRJc3N1ZVN0YXRlUmVzcG9uc2USIAoFaXNzdWUYASABKAsyES5jb21wYXNzLnYxLklzc3VlIpcBCg9Cb2FyZENhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRI8Cg9zZXRfaXNzdWVfc3RhdGUYAiABKAsyIS5jb21wYXNzLnYxLlNldElzc3VlU3RhdGVSZXNwb25zZUgAEisKBWVycm9yGAMgASgLMhouY29tcGFzcy52MS5Cb2FyZENhbGxFcnJvckgAQggKBnJlc3VsdCIvCg5Cb2FyZENhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiPAoTUHVibGlzaEZyYW1lUmVxdWVzdBIlCgVmcmFtZRgBIAEoCzIWLmNvbXBhc3MudjEuQWdlbnRGcmFtZSIWChRQdWJsaXNoRnJhbWVSZXNwb25zZSJeChxQb3N0Q29udmVyc2F0aW9uRnJhbWVSZXF1ZXN0EiUKBWZyYW1lGAEgASgLMhYuY29tcGFzcy52MS5BZ2VudEZyYW1lEhcKD2lkZW1wb3RlbmN5X2tleRgCIAEoCSIfCh1Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXNwb25zZSIZChdDb250cm9sU3Vic2NyaWJlUmVxdWVzdCI1ChVQdXRTZXNzaW9uQmxvYlJlcXVlc3QSDgoGc2hhMjU2GAEgASgJEgwKBGRhdGEYAiABKAwiGAoWUHV0U2Vzc2lvbkJsb2JSZXNwb25zZSqRAQoWRm9yZ2VTdWJzY3JpcHRpb25TY29wZRIoCiRGT1JHRV9TVUJTQ1JJUFRJT05fU0NPUEVfVU5TUEVDSUZJRUQQABIlCiFGT1JHRV9TVUJTQ1JJUFRJT05fU0NPUEVfQVJUSUZBQ1QQARImCiJGT1JHRV9TVUJTQ1JJUFRJT05fU0NPUEVfQ09OVEFJTkVSEAIyjQUKDEFnZW50R2F0ZXdheRJCCgVDb21tcxIcLmNvbXBhc3MudjEuQ29tbXNDYWxsUmVxdWVzdBobLmNvbXBhc3MudjEuQ29tbXNDYWxsUmVzdWx0Ek4KCUxpZmVjeWNsZRIgLmNvbXBhc3MudjEuTGlmZWN5Y2xlQ2FsbFJlcXVlc3QaHy5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxSZXN1bHQSTgoHUHVibGlzaBIfLmNvbXBhc3MudjEuUHVibGlzaEZyYW1lUmVxdWVzdBogLmNvbXBhc3MudjEuUHVibGlzaEZyYW1lUmVzcG9uc2UoARJsChVQb3N0Q29udmVyc2F0aW9uRnJhbWUSKC5jb21wYXNzLnYxLlBvc3RDb252ZXJzYXRpb25GcmFtZVJlcXVlc3QaKS5jb21wYXNzLnYxLlBvc3RDb252ZXJzYXRpb25GcmFtZVJlc3BvbnNlEkoKB0NvbnRyb2wSIy5jb21wYXNzLnYxLkNvbnRyb2xTdWJzY3JpYmVSZXF1ZXN0GhguY29tcGFzcy52MS5BZ2VudENvbnRyb2wwARJCCgVGb3JnZRIcLmNvbXBhc3MudjEuRm9yZ2VDYWxsUmVxdWVzdBobLmNvbXBhc3MudjEuRm9yZ2VDYWxsUmVzdWx0EkIKBUJvYXJkEhwuY29tcGFzcy52MS5Cb2FyZENhbGxSZXF1ZXN0GhsuY29tcGFzcy52MS5Cb2FyZENhbGxSZXN1bHQSVwoOUHV0U2Vzc2lvbkJsb2ISIS5jb21wYXNzLnYxLlB1dFNlc3Npb25CbG9iUmVxdWVzdBoiLmNvbXBhc3MudjEuUHV0U2Vzc2lvbkJsb2JSZXNwb25zZWIGcHJvdG8z", [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 @@ -665,6 +665,12 @@ export type ForgeCallRequest = Message<"compass.v1.ForgeCallRequest"> & { */ value: SubmitReviewRequest; case: "submitReview"; + } | { + /** + * @generated from field: compass.v1.MirrorMessagesRequest mirror_messages = 16; + */ + value: MirrorMessagesRequest; + case: "mirrorMessages"; } | { /** * @generated from field: compass.v1.TransitionIssueStateRequest transition_issue_state = 14; @@ -691,13 +697,10 @@ export type ForgeCallRequest = Message<"compass.v1.ForgeCallRequest"> & { forge?: ForgeRef | undefined; /** - * Whole-chain idempotency key for the CREATE arms (create_issue / - * create_pull_request) — the SpawnPeerRequest.client_request_id precedent - * (agent_gateway.proto:172 "whole-chain idempotency key (handler-level - * join + Provision dedup)"). A retried create with the same key returns the - * ORIGINAL artifact, never a duplicate. Distinct from call_id, which is - * correlation-only. Ignored on non-create arms. Field 13 is collision-free: - * call_id=1, oneof arms 2-11 and 14-15, forge=12. + * Whole-chain idempotency key for create and mirror_messages calls. A retry + * with the same key returns the original result without posting again. + * Distinct from call_id, which is correlation-only. Field 13 is collision-free: + * call_id=1, oneof arms 2-11, 14-16, forge=12. * * @generated from field: string client_request_id = 13; */ @@ -711,6 +714,45 @@ export type ForgeCallRequest = Message<"compass.v1.ForgeCallRequest"> & { export const ForgeCallRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_compass_v1_agent_gateway, 12); +/** + * @generated from message compass.v1.MirrorMessagesRequest + */ +export type MirrorMessagesRequest = Message<"compass.v1.MirrorMessagesRequest"> & { + /** + * @generated from field: string ref = 1; + */ + ref: string; + + /** + * @generated from field: repeated string message_ids = 2; + */ + messageIds: string[]; + + /** + * @generated from field: string topic_id = 3; + */ + topicId: string; + + /** + * A channel name, resolved for the calling agent; never a channel ID. + * + * @generated from field: string channel = 4; + */ + channel: string; + + /** + * @generated from field: string note = 5; + */ + note: string; +}; + +/** + * Describes the message compass.v1.MirrorMessagesRequest. + * Use `create(MirrorMessagesRequestSchema)` to create a new message. + */ +export const MirrorMessagesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_compass_v1_agent_gateway, 13); + /** * The result of one forge call, correlated by `call_id`. Per DL-069 no raw forge * shape is a wire type: the domain result arms retype to the canonical compass.v1 @@ -740,7 +782,7 @@ export type ForgeCallResult = Message<"compass.v1.ForgeCallResult"> & { case: "issue"; } | { /** - * comment_on_issue (write ack: url + comment_id) + * comment_on_issue / mirror_messages on an issue * * @generated from field: compass.v1.CommentRef issue_comment = 3; */ @@ -764,7 +806,7 @@ export type ForgeCallResult = Message<"compass.v1.ForgeCallResult"> & { case: "pullRequest"; } | { /** - * comment_on_pull_request (write ack) + * comment_on_pull_request / mirror_messages on a PR * * @generated from field: compass.v1.CommentRef pr_comment = 6; */ @@ -804,7 +846,7 @@ export type ForgeCallResult = Message<"compass.v1.ForgeCallResult"> & { * Use `create(ForgeCallResultSchema)` to create a new message. */ export const ForgeCallResultSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 13); + messageDesc(file_compass_v1_agent_gateway, 14); /** * An in-band forge-call failure: a tool error the agent renders to the model, @@ -839,7 +881,7 @@ export type ForgeCallError = Message<"compass.v1.ForgeCallError"> & { * Use `create(ForgeCallErrorSchema)` to create a new message. */ export const ForgeCallErrorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 14); + messageDesc(file_compass_v1_agent_gateway, 15); /** * The forge operation requests. Every field is a scalar — no forge domain @@ -884,7 +926,7 @@ export type CreateIssueRequest = Message<"compass.v1.CreateIssueRequest"> & { * Use `create(CreateIssueRequestSchema)` to create a new message. */ export const CreateIssueRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 15); + messageDesc(file_compass_v1_agent_gateway, 16); /** * @generated from message compass.v1.CommentOnIssueRequest @@ -913,7 +955,7 @@ export type CommentOnIssueRequest = Message<"compass.v1.CommentOnIssueRequest"> * Use `create(CommentOnIssueRequestSchema)` to create a new message. */ export const CommentOnIssueRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 16); + messageDesc(file_compass_v1_agent_gateway, 17); /** * @generated from message compass.v1.GetIssueRequest @@ -935,7 +977,7 @@ export type GetIssueRequest = Message<"compass.v1.GetIssueRequest"> & { * Use `create(GetIssueRequestSchema)` to create a new message. */ export const GetIssueRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 17); + messageDesc(file_compass_v1_agent_gateway, 18); /** * @generated from message compass.v1.ListIssuesRequest @@ -971,7 +1013,7 @@ export type ListIssuesRequest = Message<"compass.v1.ListIssuesRequest"> & { * Use `create(ListIssuesRequestSchema)` to create a new message. */ export const ListIssuesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 18); + messageDesc(file_compass_v1_agent_gateway, 19); /** * @generated from message compass.v1.ListIssuesResponse @@ -988,7 +1030,7 @@ export type ListIssuesResponse = Message<"compass.v1.ListIssuesResponse"> & { * Use `create(ListIssuesResponseSchema)` to create a new message. */ export const ListIssuesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 19); + messageDesc(file_compass_v1_agent_gateway, 20); /** * @generated from message compass.v1.CreatePullRequestRequest @@ -1044,7 +1086,7 @@ export type CreatePullRequestRequest = Message<"compass.v1.CreatePullRequestRequ * Use `create(CreatePullRequestRequestSchema)` to create a new message. */ export const CreatePullRequestRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 20); + messageDesc(file_compass_v1_agent_gateway, 21); /** * @generated from message compass.v1.PullRequestIssueLink @@ -1075,7 +1117,7 @@ export type PullRequestIssueLink = Message<"compass.v1.PullRequestIssueLink"> & * Use `create(PullRequestIssueLinkSchema)` to create a new message. */ export const PullRequestIssueLinkSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 21); + messageDesc(file_compass_v1_agent_gateway, 22); /** * @generated from message compass.v1.CommentOnPullRequestRequest @@ -1104,7 +1146,7 @@ export type CommentOnPullRequestRequest = Message<"compass.v1.CommentOnPullReque * Use `create(CommentOnPullRequestRequestSchema)` to create a new message. */ export const CommentOnPullRequestRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 22); + messageDesc(file_compass_v1_agent_gateway, 23); /** * @generated from message compass.v1.GetPullRequestRequest @@ -1126,7 +1168,7 @@ export type GetPullRequestRequest = Message<"compass.v1.GetPullRequestRequest"> * Use `create(GetPullRequestRequestSchema)` to create a new message. */ export const GetPullRequestRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 23); + messageDesc(file_compass_v1_agent_gateway, 24); /** * @generated from message compass.v1.SubmitReviewRequest @@ -1171,7 +1213,7 @@ export type SubmitReviewRequest = Message<"compass.v1.SubmitReviewRequest"> & { * Use `create(SubmitReviewRequestSchema)` to create a new message. */ export const SubmitReviewRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 24); + messageDesc(file_compass_v1_agent_gateway, 25); /** * @generated from message compass.v1.ReviewCommentInput @@ -1211,7 +1253,7 @@ export type ReviewCommentInput = Message<"compass.v1.ReviewCommentInput"> & { * Use `create(ReviewCommentInputSchema)` to create a new message. */ export const ReviewCommentInputSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 25); + messageDesc(file_compass_v1_agent_gateway, 26); /** * Move an existing artifact between forge states. Mutates a coordinate and @@ -1269,7 +1311,7 @@ export type TransitionIssueStateRequest = Message<"compass.v1.TransitionIssueSta * Use `create(TransitionIssueStateRequestSchema)` to create a new message. */ export const TransitionIssueStateRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 26); + messageDesc(file_compass_v1_agent_gateway, 27); /** * The PR twin. No refinement fields: `close_reason` is a GitHub *issue* @@ -1303,7 +1345,7 @@ export type TransitionPullRequestStateRequest = Message<"compass.v1.TransitionPu * Use `create(TransitionPullRequestStateRequestSchema)` to create a new message. */ export const TransitionPullRequestStateRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 27); + messageDesc(file_compass_v1_agent_gateway, 28); /** * @generated from message compass.v1.SubscribeForgeRequest @@ -1348,7 +1390,7 @@ export type SubscribeForgeRequest = Message<"compass.v1.SubscribeForgeRequest"> * Use `create(SubscribeForgeRequestSchema)` to create a new message. */ export const SubscribeForgeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 28); + messageDesc(file_compass_v1_agent_gateway, 29); /** * @generated from message compass.v1.SubscribeForgeResponse @@ -1365,7 +1407,7 @@ export type SubscribeForgeResponse = Message<"compass.v1.SubscribeForgeResponse" * Use `create(SubscribeForgeResponseSchema)` to create a new message. */ export const SubscribeForgeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 29); + messageDesc(file_compass_v1_agent_gateway, 30); /** * @generated from message compass.v1.UnsubscribeForgeRequest @@ -1382,7 +1424,7 @@ export type UnsubscribeForgeRequest = Message<"compass.v1.UnsubscribeForgeReques * Use `create(UnsubscribeForgeRequestSchema)` to create a new message. */ export const UnsubscribeForgeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 30); + messageDesc(file_compass_v1_agent_gateway, 31); /** * @generated from message compass.v1.UnsubscribeForgeResponse @@ -1395,7 +1437,7 @@ export type UnsubscribeForgeResponse = Message<"compass.v1.UnsubscribeForgeRespo * Use `create(UnsubscribeForgeResponseSchema)` to create a new message. */ export const UnsubscribeForgeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 31); + messageDesc(file_compass_v1_agent_gateway, 32); /** * One agent-initiated board call. `call_id` is the agent-minted correlation id @@ -1429,7 +1471,7 @@ export type BoardCallRequest = Message<"compass.v1.BoardCallRequest"> & { * Use `create(BoardCallRequestSchema)` to create a new message. */ export const BoardCallRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 32); + messageDesc(file_compass_v1_agent_gateway, 33); /** * Set an issue's canonical lifecycle state. Carries the full @@ -1463,7 +1505,7 @@ export type SetIssueStateRequest = Message<"compass.v1.SetIssueStateRequest"> & * Use `create(SetIssueStateRequestSchema)` to create a new message. */ export const SetIssueStateRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 33); + messageDesc(file_compass_v1_agent_gateway, 34); /** * The post-transition truth, correlated in BoardCallResult. Unchanged on a no-op. @@ -1482,7 +1524,7 @@ export type SetIssueStateResponse = Message<"compass.v1.SetIssueStateResponse"> * Use `create(SetIssueStateResponseSchema)` to create a new message. */ export const SetIssueStateResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 34); + messageDesc(file_compass_v1_agent_gateway, 35); /** * The result of one board call, correlated by `call_id`. A successful call sets @@ -1521,7 +1563,7 @@ export type BoardCallResult = Message<"compass.v1.BoardCallResult"> & { * Use `create(BoardCallResultSchema)` to create a new message. */ export const BoardCallResultSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 35); + messageDesc(file_compass_v1_agent_gateway, 36); /** * An in-band board-call failure: a tool error the agent renders to the model, @@ -1547,7 +1589,7 @@ export type BoardCallError = Message<"compass.v1.BoardCallError"> & { * Use `create(BoardCallErrorSchema)` to create a new message. */ export const BoardCallErrorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 36); + messageDesc(file_compass_v1_agent_gateway, 37); /** * Publish stream element: one trace/session AgentFrame, in emission order. No @@ -1571,7 +1613,7 @@ export type PublishFrameRequest = Message<"compass.v1.PublishFrameRequest"> & { * Use `create(PublishFrameRequestSchema)` to create a new message. */ export const PublishFrameRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 37); + messageDesc(file_compass_v1_agent_gateway, 38); /** * Acked at stream close, mirroring RunnerService.PublishEvents' PublishEventsResponse. @@ -1586,7 +1628,7 @@ export type PublishFrameResponse = Message<"compass.v1.PublishFrameResponse"> & * Use `create(PublishFrameResponseSchema)` to create a new message. */ export const PublishFrameResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 38); + messageDesc(file_compass_v1_agent_gateway, 39); /** * The durable-frame unary carries the SAME AgentFrame message, constrained by @@ -1619,7 +1661,7 @@ export type PostConversationFrameRequest = Message<"compass.v1.PostConversationF * Use `create(PostConversationFrameRequestSchema)` to create a new message. */ export const PostConversationFrameRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 39); + messageDesc(file_compass_v1_agent_gateway, 40); /** * Returned only after the upstream PublishEvents forward is accepted. @@ -1634,7 +1676,7 @@ export type PostConversationFrameResponse = Message<"compass.v1.PostConversation * Use `create(PostConversationFrameResponseSchema)` to create a new message. */ export const PostConversationFrameResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 40); + messageDesc(file_compass_v1_agent_gateway, 41); /** * The Control subscribe request carries no session id: the per-container socket @@ -1650,7 +1692,7 @@ export type ControlSubscribeRequest = Message<"compass.v1.ControlSubscribeReques * Use `create(ControlSubscribeRequestSchema)` to create a new message. */ export const ControlSubscribeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 41); + messageDesc(file_compass_v1_agent_gateway, 42); /** * One content-addressed session blob. sha256 is lowercase hex of data; the @@ -1675,7 +1717,7 @@ export type PutSessionBlobRequest = Message<"compass.v1.PutSessionBlobRequest"> * Use `create(PutSessionBlobRequestSchema)` to create a new message. */ export const PutSessionBlobRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 42); + messageDesc(file_compass_v1_agent_gateway, 43); /** * @generated from message compass.v1.PutSessionBlobResponse @@ -1688,7 +1730,7 @@ export type PutSessionBlobResponse = Message<"compass.v1.PutSessionBlobResponse" * Use `create(PutSessionBlobResponseSchema)` to create a new message. */ export const PutSessionBlobResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_compass_v1_agent_gateway, 43); + messageDesc(file_compass_v1_agent_gateway, 44); /** * Subscribe/unsubscribe a forge artifact for change notifications (DL-053). The diff --git a/proto/compass/v1/agent_gateway.proto b/proto/compass/v1/agent_gateway.proto index b11d7827f..2ba30f7a0 100644 --- a/proto/compass/v1/agent_gateway.proto +++ b/proto/compass/v1/agent_gateway.proto @@ -279,6 +279,7 @@ message ForgeCallRequest { SubscribeForgeRequest subscribe = 9; // DL-053 UnsubscribeForgeRequest unsubscribe = 10; // DL-053 SubmitReviewRequest submit_review = 11; + MirrorMessagesRequest mirror_messages = 16; TransitionIssueStateRequest transition_issue_state = 14; TransitionPullRequestStateRequest transition_pull_request_state = 15; } @@ -288,16 +289,22 @@ message ForgeCallRequest { // selected provider's default host (A3) — never a panic, never a silent // provider fall-through. compass.v1.ForgeRef forge = 12; - // Whole-chain idempotency key for the CREATE arms (create_issue / - // create_pull_request) — the SpawnPeerRequest.client_request_id precedent - // (agent_gateway.proto:172 "whole-chain idempotency key (handler-level - // join + Provision dedup)"). A retried create with the same key returns the - // ORIGINAL artifact, never a duplicate. Distinct from call_id, which is - // correlation-only. Ignored on non-create arms. Field 13 is collision-free: - // call_id=1, oneof arms 2-11 and 14-15, forge=12. + // Whole-chain idempotency key for create and mirror_messages calls. A retry + // with the same key returns the original result without posting again. + // Distinct from call_id, which is correlation-only. Field 13 is collision-free: + // call_id=1, oneof arms 2-11, 14-16, forge=12. string client_request_id = 13; } +message MirrorMessagesRequest { + string ref = 1; + repeated string message_ids = 2; + string topic_id = 3; + // A channel name, resolved for the calling agent; never a channel ID. + string channel = 4; + string note = 5; +} + // The result of one forge call, correlated by `call_id`. Per DL-069 no raw forge // shape is a wire type: the domain result arms retype to the canonical compass.v1 // types (DL-092 supersedes #995's forge-shaped Issue/PullRequest domain messages), @@ -308,14 +315,14 @@ message ForgeCallResult { string call_id = 1; oneof result { compass.v1.Issue issue = 2; // create_issue / get_issue / transition_issue_state - CommentRef issue_comment = 3; // comment_on_issue (write ack: url + comment_id) + CommentRef issue_comment = 3; // comment_on_issue / mirror_messages on an issue ListIssuesResponse issues = 4; // list_issues compass.v1.PullRequest pull_request = 5; // create_pull_request / get_pull_request / transition_pull_request_state - CommentRef pr_comment = 6; // comment_on_pull_request (write ack) + CommentRef pr_comment = 6; // comment_on_pull_request / mirror_messages on a PR SubscribeForgeResponse subscribed = 7; UnsubscribeForgeResponse unsubscribed = 8; ForgeCallError error = 9; - ReviewRef review = 10; // submit_review (write ack: url + review_id) + ReviewRef review = 10; // submit_review (write ack: url + review_id) } } From 308ef5e727bea2f972a283a4c37eb6a6bbfbd76c Mon Sep 17 00:00:00 2001 From: mintaka Date: Thu, 15 Oct 2026 16:05:35 -0400 Subject: [PATCH 2/8] fix(mirror): make mirror retries return the stored outcome (RIG-5174) - A same-key retry returns the stored result before policy and visibility checks, refuses a different target, and classifies from the stored target. - forge_mirror_requests records each re-mirror key, so retrying it never bumps or edits again. - forge_mirrors.last_error_code keeps a failure's code for retries. - Restore the forge tool repo-validation tests and tighten the redrive tests. Co-authored-by: Matt Wilkinson --- go/internal/mirror/drive.go | 20 +- go/internal/store/db/forge_mirrors.sql.go | 161 ++++++++++-- go/internal/store/db/models.go | 9 + go/internal/store/db/querier.go | 2 + go/internal/store/forge_mirrors.go | 67 ++++- .../store/forge_mirrors_pgtest_test.go | 56 +++++ go/internal/store/migrations/0001_init.sql | 14 +- go/internal/store/queries/forge_mirrors.sql | 35 ++- go/server/forge_mirror.go | 3 + go/server/forge_mirror_agent.go | 70 ++++-- go/server/forge_mirror_agent_pgtest_test.go | 232 ++++++++++++++---- go/server/forge_test.go | 8 +- packages/compass-agent/src/forge.test.ts | 20 +- packages/compass-agent/src/forge.ts | 2 +- 14 files changed, 596 insertions(+), 103 deletions(-) diff --git a/go/internal/mirror/drive.go b/go/internal/mirror/drive.go index 3d5d9f9a8..5e6ac4006 100644 --- a/go/internal/mirror/drive.go +++ b/go/internal/mirror/drive.go @@ -383,13 +383,31 @@ func markDelivered(ctx context.Context, st *store.Store, row store.ForgeMirrorRo func markFailed(ctx context.Context, st *store.Store, row store.ForgeMirrorRow, token string, cause error) error { if err := st.MarkForgeMirror(ctx, row.ID, token, store.ForgeMirrorMark{ - PostedRev: row.PostedRev, State: failedState, Err: cause.Error(), + PostedRev: row.PostedRev, State: failedState, Err: cause.Error(), ErrCode: FailureCode(cause), }); err != nil { return fmt.Errorf("mirror: mark failed: %w", err) } return nil } +// CodedError is a failure that names its Connect status code string, such as +// a poster refusal, so a failed row reports the same status on every retry. +type CodedError interface { + error + FailureCode() string +} + +// FailureCode is the Connect code string stored with a failed row's error. +func FailureCode(err error) string { + if coded, ok := errors.AsType[CodedError](err); ok { + return coded.FailureCode() + } + if errors.Is(err, forge.ErrBodyTooLarge) { + return "invalid_argument" + } + return "internal" +} + func renewLease(ctx context.Context, st *store.Store, id, token string, log *slog.Logger) (chan struct{}, *atomic.Bool, chan struct{}) { stop := make(chan struct{}) done := make(chan struct{}) diff --git a/go/internal/store/db/forge_mirrors.sql.go b/go/internal/store/db/forge_mirrors.sql.go index 5cb0b5e37..1ab9330c0 100644 --- a/go/internal/store/db/forge_mirrors.sql.go +++ b/go/internal/store/db/forge_mirrors.sql.go @@ -17,7 +17,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code FROM forge_mirrors WHERE ask_id = ANY($1::text[]) ORDER BY created_at, id ` @@ -47,6 +47,7 @@ type AskForgeMirrorsRow struct { UpdatedAt pgtype.Timestamptz SessionID string SourcePayload []byte + LastErrorCode string } func (q *Queries) AskForgeMirrors(ctx context.Context, askIds []string) ([]AskForgeMirrorsRow, error) { @@ -84,6 +85,7 @@ func (q *Queries) AskForgeMirrors(ctx context.Context, askIds []string) ([]AskFo &i.UpdatedAt, &i.SessionID, &i.SourcePayload, + &i.LastErrorCode, ); err != nil { return nil, err } @@ -105,7 +107,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code ` type BeginForgeMirrorAttemptParams struct { @@ -139,6 +141,7 @@ type BeginForgeMirrorAttemptRow struct { UpdatedAt pgtype.Timestamptz SessionID string SourcePayload []byte + LastErrorCode string } func (q *Queries) BeginForgeMirrorAttempt(ctx context.Context, arg BeginForgeMirrorAttemptParams) (BeginForgeMirrorAttemptRow, error) { @@ -170,6 +173,7 @@ func (q *Queries) BeginForgeMirrorAttempt(ctx context.Context, arg BeginForgeMir &i.UpdatedAt, &i.SessionID, &i.SourcePayload, + &i.LastErrorCode, ) return i, err } @@ -218,7 +222,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code ` type ClaimForgeMirrorParams struct { @@ -262,6 +266,7 @@ type ClaimForgeMirrorRow struct { UpdatedAt pgtype.Timestamptz SessionID string SourcePayload []byte + LastErrorCode string } func (q *Queries) ClaimForgeMirror(ctx context.Context, arg ClaimForgeMirrorParams) (ClaimForgeMirrorRow, error) { @@ -306,6 +311,7 @@ func (q *Queries) ClaimForgeMirror(ctx context.Context, arg ClaimForgeMirrorPara &i.UpdatedAt, &i.SessionID, &i.SourcePayload, + &i.LastErrorCode, ) return i, err } @@ -332,7 +338,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code, CASE WHEN agent_account_id = NULLIF($1::text, '') AND client_request_id = $2::text THEN 2::smallint WHEN ask_id = NULLIF($3::text, '') THEN 1::smallint @@ -381,6 +387,7 @@ type ForgeMirrorByConflictKeysRow struct { UpdatedAt pgtype.Timestamptz SessionID string SourcePayload []byte + LastErrorCode string ConflictKind int16 } @@ -419,6 +426,7 @@ func (q *Queries) ForgeMirrorByConflictKeys(ctx context.Context, arg ForgeMirror &i.UpdatedAt, &i.SessionID, &i.SourcePayload, + &i.LastErrorCode, &i.ConflictKind, ) return i, err @@ -430,7 +438,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code FROM forge_mirrors WHERE id = $1 ` @@ -460,6 +468,7 @@ type ForgeMirrorByIDRow struct { UpdatedAt pgtype.Timestamptz SessionID string SourcePayload []byte + LastErrorCode string } func (q *Queries) ForgeMirrorByID(ctx context.Context, id string) (ForgeMirrorByIDRow, error) { @@ -491,6 +500,91 @@ func (q *Queries) ForgeMirrorByID(ctx context.Context, id string) (ForgeMirrorBy &i.UpdatedAt, &i.SessionID, &i.SourcePayload, + &i.LastErrorCode, + ) + return i, err +} + +const forgeMirrorByRequest = `-- name: ForgeMirrorByRequest :one +SELECT id, source, ask_id, source_key, actor_account_id, + COALESCE(agent_account_id, '')::text AS agent_account_id, + client_request_id, target_ref, body_rev, posted_rev, state, attempts, + comment_id, comment_key, comment_url, last_error, first_attempt_at, + last_attempt_at, client_comment_id::text AS client_comment_id, + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code + FROM forge_mirrors + WHERE agent_account_id = $1::text + AND (client_request_id = $2::text + OR id = (SELECT mirror_id FROM forge_mirror_requests r + WHERE r.agent_account_id = $1::text + AND r.client_request_id = $2::text)) + LIMIT 1 +` + +type ForgeMirrorByRequestParams struct { + AgentAccountID string + ClientRequestID string +} + +type ForgeMirrorByRequestRow struct { + ID string + Source int16 + AskID string + SourceKey string + ActorAccountID string + AgentAccountID string + ClientRequestID pgtype.Text + TargetRef string + BodyRev int16 + PostedRev int16 + State int16 + Attempts int32 + CommentID int64 + CommentKey string + CommentUrl string + LastError string + FirstAttemptAt pgtype.Timestamptz + LastAttemptAt pgtype.Timestamptz + ClientCommentID string + RecheckAt pgtype.Timestamptz + LeaseUntil pgtype.Timestamptz + LeaseToken string + UpdatedAt pgtype.Timestamptz + SessionID string + SourcePayload []byte + LastErrorCode string +} + +func (q *Queries) ForgeMirrorByRequest(ctx context.Context, arg ForgeMirrorByRequestParams) (ForgeMirrorByRequestRow, error) { + row := q.db.QueryRow(ctx, forgeMirrorByRequest, arg.AgentAccountID, arg.ClientRequestID) + var i ForgeMirrorByRequestRow + err := row.Scan( + &i.ID, + &i.Source, + &i.AskID, + &i.SourceKey, + &i.ActorAccountID, + &i.AgentAccountID, + &i.ClientRequestID, + &i.TargetRef, + &i.BodyRev, + &i.PostedRev, + &i.State, + &i.Attempts, + &i.CommentID, + &i.CommentKey, + &i.CommentUrl, + &i.LastError, + &i.FirstAttemptAt, + &i.LastAttemptAt, + &i.ClientCommentID, + &i.RecheckAt, + &i.LeaseUntil, + &i.LeaseToken, + &i.UpdatedAt, + &i.SessionID, + &i.SourcePayload, + &i.LastErrorCode, ) return i, err } @@ -552,7 +646,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code ` type LeaseForgeMirrorParams struct { @@ -587,6 +681,7 @@ type LeaseForgeMirrorRow struct { UpdatedAt pgtype.Timestamptz SessionID string SourcePayload []byte + LastErrorCode string } func (q *Queries) LeaseForgeMirror(ctx context.Context, arg LeaseForgeMirrorParams) (LeaseForgeMirrorRow, error) { @@ -618,6 +713,7 @@ func (q *Queries) LeaseForgeMirror(ctx context.Context, arg LeaseForgeMirrorPara &i.UpdatedAt, &i.SessionID, &i.SourcePayload, + &i.LastErrorCode, ) return i, err } @@ -675,24 +771,26 @@ UPDATE forge_mirrors comment_key = CASE WHEN $4::text = '' THEN comment_key ELSE $4 END, comment_url = CASE WHEN $5::text = '' THEN comment_url ELSE $5 END, last_error = $6, - recheck_at = CASE WHEN $7::boolean THEN now() + interval '10 minutes' - WHEN $8::boolean THEN NULL + last_error_code = $7, + recheck_at = CASE WHEN $8::boolean THEN now() + interval '10 minutes' + WHEN $9::boolean THEN NULL ELSE recheck_at END, lease_until = NULL, lease_token = '' - WHERE id = $9 AND lease_token = $10 AND lease_until > now() + WHERE id = $10 AND lease_token = $11 AND lease_until > now() ` type MarkForgeMirrorParams struct { - PostedRev int16 - State int16 - CommentID int64 - CommentKey string - CommentUrl string - LastError string - Recheck bool - ClearRecheck bool - ID string - LeaseToken string + PostedRev int16 + State int16 + CommentID int64 + CommentKey string + CommentUrl string + LastError string + LastErrorCode string + Recheck bool + ClearRecheck bool + ID string + LeaseToken string } func (q *Queries) MarkForgeMirror(ctx context.Context, arg MarkForgeMirrorParams) (int64, error) { @@ -703,6 +801,7 @@ func (q *Queries) MarkForgeMirror(ctx context.Context, arg MarkForgeMirrorParams arg.CommentKey, arg.CommentUrl, arg.LastError, + arg.LastErrorCode, arg.Recheck, arg.ClearRecheck, arg.ID, @@ -720,7 +819,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code FROM forge_mirrors WHERE agent_account_id = $1::text AND (state <> 2 OR recheck_at <= now()) ORDER BY created_at, id @@ -752,6 +851,7 @@ type OpenForgeMirrorsRow struct { UpdatedAt pgtype.Timestamptz SessionID string SourcePayload []byte + LastErrorCode string } func (q *Queries) OpenForgeMirrors(ctx context.Context, agentAccountID string) ([]OpenForgeMirrorsRow, error) { @@ -789,6 +889,7 @@ func (q *Queries) OpenForgeMirrors(ctx context.Context, agentAccountID string) ( &i.UpdatedAt, &i.SessionID, &i.SourcePayload, + &i.LastErrorCode, ); err != nil { return nil, err } @@ -800,6 +901,26 @@ func (q *Queries) OpenForgeMirrors(ctx context.Context, agentAccountID string) ( return items, nil } +const recordForgeMirrorRequest = `-- name: RecordForgeMirrorRequest :execrows +INSERT INTO forge_mirror_requests (agent_account_id, client_request_id, mirror_id) +VALUES ($1, $2, $3) +ON CONFLICT DO NOTHING +` + +type RecordForgeMirrorRequestParams struct { + AgentAccountID string + ClientRequestID string + MirrorID string +} + +func (q *Queries) RecordForgeMirrorRequest(ctx context.Context, arg RecordForgeMirrorRequestParams) (int64, error) { + result, err := q.db.Exec(ctx, recordForgeMirrorRequest, arg.AgentAccountID, arg.ClientRequestID, arg.MirrorID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const renewForgeMirror = `-- name: RenewForgeMirror :execrows UPDATE forge_mirrors SET lease_until = now() + $1::interval WHERE id = $2 AND lease_token = $3 AND lease_until > now() diff --git a/go/internal/store/db/models.go b/go/internal/store/db/models.go index 9075c464c..4cd33b4c5 100644 --- a/go/internal/store/db/models.go +++ b/go/internal/store/db/models.go @@ -308,6 +308,7 @@ type ForgeMirror struct { CommentKey string CommentUrl string LastError string + LastErrorCode string FirstAttemptAt pgtype.Timestamptz LastAttemptAt pgtype.Timestamptz ClientCommentID pgtype.UUID @@ -319,6 +320,14 @@ type ForgeMirror struct { TenantID string } +type ForgeMirrorRequest struct { + AgentAccountID string + ClientRequestID string + MirrorID string + CreatedAt pgtype.Timestamptz + TenantID string +} + type ForgeMirrorSetting struct { ForgeProvider int16 ForgeHost string diff --git a/go/internal/store/db/querier.go b/go/internal/store/db/querier.go index 91eb94b89..3f758ef71 100644 --- a/go/internal/store/db/querier.go +++ b/go/internal/store/db/querier.go @@ -213,6 +213,7 @@ type Querier interface { FindAskMessage(ctx context.Context, arg FindAskMessageParams) ([]FindAskMessageRow, error) ForgeMirrorByConflictKeys(ctx context.Context, arg ForgeMirrorByConflictKeysParams) (ForgeMirrorByConflictKeysRow, error) ForgeMirrorByID(ctx context.Context, id string) (ForgeMirrorByIDRow, error) + ForgeMirrorByRequest(ctx context.Context, arg ForgeMirrorByRequestParams) (ForgeMirrorByRequestRow, error) // Ask mirroring settings and outbox persistence. ForgeMirrorMode(ctx context.Context, arg ForgeMirrorModeParams) (int16, error) ForgeScopeUserExists(ctx context.Context, accountID string) (bool, error) @@ -606,6 +607,7 @@ type Querier interface { // WRITE-ONCE authorship: the DO UPDATE deliberately omits agent_account_id and // owner_user_id, so a re-land never rewrites who authored the artifact. RecordAuthoredArtifact(ctx context.Context, arg RecordAuthoredArtifactParams) error + RecordForgeMirrorRequest(ctx context.Context, arg RecordForgeMirrorRequestParams) (int64, error) // Runs under the BYPASSRLS system role (delivery consumer, no tenant GUC), so // tenant_id is stamped explicitly from the owning account's FK rather than the // column DEFAULT (which would NULL-violate with no GUC). The INSERT..SELECT diff --git a/go/internal/store/forge_mirrors.go b/go/internal/store/forge_mirrors.go index eaa833d67..b0f30df76 100644 --- a/go/internal/store/forge_mirrors.go +++ b/go/internal/store/forge_mirrors.go @@ -47,6 +47,9 @@ type ForgeMirrorRow struct { CommentKey string CommentURL string LastError string + // LastErrorCode is the Connect code of a failed row's error, so a retry + // reports the same status the failing drive did. + LastErrorCode string FirstAttemptAt *time.Time LastAttemptAt *time.Time ClientCommentID string @@ -69,6 +72,7 @@ type ForgeMirrorMark struct { CommentKey string URL string Err string + ErrCode string Recheck bool ClearRecheck bool } @@ -176,11 +180,8 @@ func (s *Store) ClaimForgeMirror(ctx context.Context, mirror ForgeMirrorRow) (Fo return ForgeMirrorRow{}, false, fmt.Errorf("store: find claimed forge mirror: %w", err) } if conflict.ConflictKind == 3 && mirror.Source == 2 && conflict.ActorAccountID == string(mirror.Actor) && conflict.AgentAccountID == string(mirror.Agent) { - // A re-mirror of the same source carries its new render inputs and session. - if _, err := s.q.BumpForgeMirrorSourceRevision(ctx, db.BumpForgeMirrorSourceRevisionParams{ - SourcePayload: mirror.SourcePayload, SessionID: mirror.SessionID, ID: conflict.ID, - }); err != nil { - return ForgeMirrorRow{}, false, fmt.Errorf("store: bump forge mirror revision: %w", err) + if err := s.remirrorSource(ctx, conflict.ID, mirror); err != nil { + return ForgeMirrorRow{}, false, err } conflict, err = s.forgeMirrorByConflictKeys(ctx, mirror) if err != nil { @@ -196,10 +197,60 @@ func (s *Store) ClaimForgeMirror(ctx context.Context, mirror ForgeMirrorRow) (Fo FirstAttemptAt: conflict.FirstAttemptAt, LastAttemptAt: conflict.LastAttemptAt, ClientCommentID: conflict.ClientCommentID, RecheckAt: conflict.RecheckAt, LeaseUntil: conflict.LeaseUntil, LeaseToken: conflict.LeaseToken, UpdatedAt: conflict.UpdatedAt, - SessionID: conflict.SessionID, SourcePayload: conflict.SourcePayload, + SessionID: conflict.SessionID, SourcePayload: conflict.SourcePayload, LastErrorCode: conflict.LastErrorCode, }), false, nil } +// remirrorSource bumps a source row for a later agent request. The request key +// is recorded in the same transaction and only a first-seen key bumps, so a +// retry of the re-mirror call never renders or edits again. +func (s *Store) remirrorSource(ctx context.Context, id string, mirror ForgeMirrorRow) error { + tx, err := s.beginTenantTx(ctx) + if err != nil { + return fmt.Errorf("store: begin re-mirror: %w", err) + } + // After Commit this rollback is a no-op; a failure before Commit is returned below. + defer func() { _ = tx.Rollback(ctx) }() + qtx := s.q.WithTx(tx) + if mirror.Agent != "" && mirror.ClientRequestID != "" { + recorded, err := qtx.RecordForgeMirrorRequest(ctx, db.RecordForgeMirrorRequestParams{ + AgentAccountID: string(mirror.Agent), ClientRequestID: mirror.ClientRequestID, MirrorID: id, + }) + if err != nil { + return fmt.Errorf("store: record forge mirror request: %w", err) + } + if recorded == 0 { + return nil + } + } + // A re-mirror of the same source carries its new render inputs and session. + if _, err := qtx.BumpForgeMirrorSourceRevision(ctx, db.BumpForgeMirrorSourceRevisionParams{ + SourcePayload: mirror.SourcePayload, SessionID: mirror.SessionID, ID: id, + }); err != nil { + return fmt.Errorf("store: bump forge mirror revision: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("store: commit re-mirror: %w", err) + } + return nil +} + +// ForgeMirrorByRequest finds the row an agent request key claimed or +// re-mirrored; ok is false when the key is unused. +func (s *Store) ForgeMirrorByRequest(ctx context.Context, agent AccountID, clientRequestID string) (ForgeMirrorRow, bool, error) { + if agent == "" || clientRequestID == "" { + return ForgeMirrorRow{}, false, fmt.Errorf("%w: forge mirror agent and request key are required", ErrInvalidArgument) + } + row, err := s.q.ForgeMirrorByRequest(ctx, db.ForgeMirrorByRequestParams{AgentAccountID: string(agent), ClientRequestID: clientRequestID}) + if noRows(err) { + return ForgeMirrorRow{}, false, nil + } + if err != nil { + return ForgeMirrorRow{}, false, fmt.Errorf("store: read forge mirror by request: %w", err) + } + return forgeMirrorFromDB(db.ClaimForgeMirrorRow(row)), true, nil +} + func (s *Store) forgeMirrorByConflictKeys(ctx context.Context, mirror ForgeMirrorRow) (db.ForgeMirrorByConflictKeysRow, error) { return s.q.ForgeMirrorByConflictKeys(ctx, db.ForgeMirrorByConflictKeysParams{ AskID: mirror.AskID, AgentAccountID: string(mirror.Agent), @@ -307,7 +358,7 @@ func (s *Store) MarkForgeMirror(ctx context.Context, id, token string, mark Forg n, err := s.q.MarkForgeMirror(ctx, db.MarkForgeMirrorParams{ ID: id, LeaseToken: token, PostedRev: mark.PostedRev, State: mark.State, CommentID: int64(mark.CommentID), CommentKey: mark.CommentKey, CommentUrl: mark.URL, - LastError: mark.Err, Recheck: mark.Recheck, ClearRecheck: mark.ClearRecheck, + LastError: mark.Err, LastErrorCode: mark.ErrCode, Recheck: mark.Recheck, ClearRecheck: mark.ClearRecheck, }) if err != nil { return fmt.Errorf("store: mark forge mirror: %w", err) @@ -400,6 +451,6 @@ func forgeMirrorFromDB(row db.ClaimForgeMirrorRow) ForgeMirrorRow { FirstAttemptAt: optionalTime(row.FirstAttemptAt), LastAttemptAt: optionalTime(row.LastAttemptAt), ClientCommentID: row.ClientCommentID, RecheckAt: optionalTime(row.RecheckAt), LeaseUntil: optionalTime(row.LeaseUntil), LeaseToken: row.LeaseToken, UpdatedAt: row.UpdatedAt.Time, - SessionID: row.SessionID, SourcePayload: row.SourcePayload, + SessionID: row.SessionID, SourcePayload: row.SourcePayload, LastErrorCode: row.LastErrorCode, } } diff --git a/go/internal/store/forge_mirrors_pgtest_test.go b/go/internal/store/forge_mirrors_pgtest_test.go index 4e9c99d1b..32a714ec4 100644 --- a/go/internal/store/forge_mirrors_pgtest_test.go +++ b/go/internal/store/forge_mirrors_pgtest_test.go @@ -603,6 +603,62 @@ func TestClaimForgeMirrorStoresSessionAndPayloadAndRemirrorUpdatesThem(t *testin } } +// A retry of the re-mirror key finds the row and does not bump it again. +func TestRemirrorRequestKeyIsRecordedOnce(t *testing.T) { + ctx := context.Background() // test root context + s := newTestStore(t) + agent, _ := seedAgent(t, s, "mirror-request-key") + claim := func(key string) ForgeMirrorRow { + t.Helper() + row, _, err := s.ClaimForgeMirror(ctx, ForgeMirrorRow{ + Source: 2, SourceKey: "topic-k", Actor: agent, Agent: agent, ClientRequestID: key, + TargetRef: "owner/repo#5", BodyRev: 1, State: 1, SessionID: "sess-k", SourcePayload: []byte(`{}`), + }) + if err != nil { + t.Fatalf("ClaimForgeMirror(%s): %v", key, err) + } + return row + } + first := claim("call-1") + if bumped := claim("call-2"); bumped.BodyRev != 2 { + t.Fatalf("re-mirror body_rev = %d, want 2", bumped.BodyRev) + } + if again := claim("call-2"); again.BodyRev != 2 { + t.Fatalf("re-mirror retry body_rev = %d, want unchanged 2", again.BodyRev) + } + for _, key := range []string{"call-1", "call-2"} { + row, found, err := s.ForgeMirrorByRequest(ctx, agent, key) + if err != nil || !found || row.ID != first.ID { + t.Fatalf("ForgeMirrorByRequest(%s) = (%+v, found=%v, err=%v), want row %s", key, row, found, err, first.ID) + } + } + if _, found, err := s.ForgeMirrorByRequest(ctx, agent, "call-unused"); err != nil || found { + t.Fatalf("ForgeMirrorByRequest(unused) = (found=%v, err=%v), want not found", found, err) + } +} + +func TestMarkForgeMirrorStoresFailureCode(t *testing.T) { + ctx := context.Background() // test root context + s := newTestStore(t) + agent, _ := seedAgent(t, s, "mirror-error-code") + row, _, err := s.ClaimForgeMirror(ctx, ForgeMirrorRow{ + Source: 2, SourceKey: "topic-e", Actor: agent, Agent: agent, TargetRef: "owner/repo#6", BodyRev: 1, State: 1, + }) + if err != nil { + t.Fatalf("ClaimForgeMirror: %v", err) + } + if _, ok, err := s.LeaseForgeMirror(ctx, row.ID, "code-token", time.Minute); err != nil || !ok { + t.Fatalf("LeaseForgeMirror = (ok=%v, err=%v)", ok, err) + } + if err := s.MarkForgeMirror(ctx, row.ID, "code-token", ForgeMirrorMark{State: 3, Err: "gone", ErrCode: "not_found"}); err != nil { + t.Fatalf("MarkForgeMirror: %v", err) + } + got, err := s.ForgeMirrorByID(ctx, row.ID) + if err != nil || got.LastError != "gone" || got.LastErrorCode != "not_found" { + t.Fatalf("ForgeMirrorByID = (%+v, %v), want the stored error and code", got, err) + } +} + // An auto ask row takes its session from the posting ctx; a post with no // session on ctx stores an empty one for the poster to refuse. func TestAskMirrorRowTakesSessionFromContext(t *testing.T) { diff --git a/go/internal/store/migrations/0001_init.sql b/go/internal/store/migrations/0001_init.sql index 6ca2996bf..2b229d63f 100644 --- a/go/internal/store/migrations/0001_init.sql +++ b/go/internal/store/migrations/0001_init.sql @@ -1120,6 +1120,7 @@ CREATE TABLE forge_mirrors ( comment_key TEXT NOT NULL DEFAULT '', comment_url TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', + last_error_code TEXT NOT NULL DEFAULT '', first_attempt_at TIMESTAMPTZ, last_attempt_at TIMESTAMPTZ, client_comment_id UUID NOT NULL DEFAULT gen_random_uuid(), @@ -1136,6 +1137,17 @@ CREATE UNIQUE INDEX forge_mirrors_request_key ON forge_mirrors (tenant_id, agent CREATE UNIQUE INDEX forge_mirrors_source_key ON forge_mirrors (tenant_id, source_key, target_ref) WHERE source_key <> ''; CREATE INDEX forge_mirrors_open_idx ON forge_mirrors (agent_account_id) WHERE state <> 2 OR recheck_at IS NOT NULL; +-- forge_mirror_requests records each later agent request key that re-mirrored +-- an existing row, so a retry of that key finds the row instead of bumping it. +CREATE TABLE forge_mirror_requests ( + agent_account_id TEXT NOT NULL REFERENCES agent_accounts (account_id) ON DELETE RESTRICT, + client_request_id TEXT NOT NULL CHECK (client_request_id <> ''), + mirror_id TEXT NOT NULL REFERENCES forge_mirrors (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + tenant_id TEXT NOT NULL DEFAULT current_setting('compass.tenant_id', TRUE), + PRIMARY KEY (tenant_id, agent_account_id, client_request_id) +); + -- pull_requests holds each ingested PR whole; pull_request_issue_links maps PRs -- to the issues they work on (source 1 explicit, 2 closing reference). CREATE TABLE pull_requests ( @@ -1721,7 +1733,7 @@ DECLARE 'agent_boards', 'agent_board_tasks', 'linear_agent_sessions', 'issues', 'forge_repo_subscriptions', 'forge_artifact_cursors', - 'forge_mirror_settings', 'forge_mirrors', + 'forge_mirror_settings', 'forge_mirrors', 'forge_mirror_requests', 'forge_state_transitions', 'account_forge_scopes', 'pull_requests', 'pull_request_issue_links', 'token_usage_events', 'token_usage_rollups_hourly', 'token_usage_rollups_daily', diff --git a/go/internal/store/queries/forge_mirrors.sql b/go/internal/store/queries/forge_mirrors.sql index 9ecfe75be..f914552b7 100644 --- a/go/internal/store/queries/forge_mirrors.sql +++ b/go/internal/store/queries/forge_mirrors.sql @@ -41,7 +41,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload; + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code; -- name: ForgeMirrorByConflictKeys :one SELECT id, source, ask_id, source_key, actor_account_id, @@ -49,7 +49,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code, CASE WHEN agent_account_id = NULLIF(sqlc.arg(agent_account_id)::text, '') AND client_request_id = sqlc.narg(client_request_id)::text THEN 2::smallint WHEN ask_id = NULLIF(sqlc.arg(ask_id)::text, '') THEN 1::smallint @@ -76,7 +76,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code FROM forge_mirrors WHERE agent_account_id = sqlc.arg(agent_account_id)::text AND (state <> 2 OR recheck_at <= now()) ORDER BY created_at, id; @@ -87,7 +87,7 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code FROM forge_mirrors WHERE ask_id = ANY(sqlc.arg(ask_ids)::text[]) ORDER BY created_at, id; -- name: LeaseForgeMirror :one @@ -100,7 +100,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload; + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code; -- name: RenewForgeMirror :execrows UPDATE forge_mirrors SET lease_until = now() + sqlc.arg(ttl)::interval @@ -116,7 +116,7 @@ RETURNING id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload; + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code; -- name: MarkForgeMirror :execrows UPDATE forge_mirrors @@ -126,6 +126,7 @@ UPDATE forge_mirrors comment_key = CASE WHEN sqlc.arg(comment_key)::text = '' THEN comment_key ELSE sqlc.arg(comment_key) END, comment_url = CASE WHEN sqlc.arg(comment_url)::text = '' THEN comment_url ELSE sqlc.arg(comment_url) END, last_error = sqlc.arg(last_error), + last_error_code = sqlc.arg(last_error_code), recheck_at = CASE WHEN sqlc.arg(recheck)::boolean THEN now() + interval '10 minutes' WHEN sqlc.arg(clear_recheck)::boolean THEN NULL ELSE recheck_at END, @@ -138,9 +139,29 @@ SELECT id, source, ask_id, source_key, actor_account_id, client_request_id, target_ref, body_rev, posted_rev, state, attempts, comment_id, comment_key, comment_url, last_error, first_attempt_at, last_attempt_at, client_comment_id::text AS client_comment_id, - recheck_at, lease_until, lease_token, updated_at, session_id, source_payload + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code FROM forge_mirrors WHERE id = sqlc.arg(id); +-- name: ForgeMirrorByRequest :one +SELECT id, source, ask_id, source_key, actor_account_id, + COALESCE(agent_account_id, '')::text AS agent_account_id, + client_request_id, target_ref, body_rev, posted_rev, state, attempts, + comment_id, comment_key, comment_url, last_error, first_attempt_at, + last_attempt_at, client_comment_id::text AS client_comment_id, + recheck_at, lease_until, lease_token, updated_at, session_id, source_payload, last_error_code + FROM forge_mirrors + WHERE agent_account_id = sqlc.arg(agent_account_id)::text + AND (client_request_id = sqlc.arg(client_request_id)::text + OR id = (SELECT mirror_id FROM forge_mirror_requests r + WHERE r.agent_account_id = sqlc.arg(agent_account_id)::text + AND r.client_request_id = sqlc.arg(client_request_id)::text)) + LIMIT 1; + +-- name: RecordForgeMirrorRequest :execrows +INSERT INTO forge_mirror_requests (agent_account_id, client_request_id, mirror_id) +VALUES (sqlc.arg(agent_account_id), sqlc.arg(client_request_id), sqlc.arg(mirror_id)) +ON CONFLICT DO NOTHING; + -- name: InsertAskForgeMirror :exec INSERT INTO forge_mirrors (id, source, ask_id, actor_account_id, agent_account_id, target_ref, body_rev, state, session_id) diff --git a/go/server/forge_mirror.go b/go/server/forge_mirror.go index daf8b4f3c..3969fd813 100644 --- a/go/server/forge_mirror.go +++ b/go/server/forge_mirror.go @@ -60,6 +60,9 @@ func (e *mirrorRefusalError) Error() string { return e.message } func (e *mirrorRefusalError) Unwrap() error { return mirror.ErrRefused } +// FailureCode lets the drive store the refusal's code with the failed row. +func (e *mirrorRefusalError) FailureCode() string { return e.code.String() } + func (s *forgeService) mirrorAllowed(ctx context.Context, account store.Account, need store.ForgeMirrorMode, ref refs.Ref) (resolvedForge, string, uint64, *compassv1internal.ForgeCallError) { if account.ID == "" { return resolvedForge{}, "", 0, forgeErr(connect.CodeUnauthenticated, "forge mirror: caller is required") diff --git a/go/server/forge_mirror_agent.go b/go/server/forge_mirror_agent.go index a64768248..ec2b76aa9 100644 --- a/go/server/forge_mirror_agent.go +++ b/go/server/forge_mirror_agent.go @@ -28,7 +28,8 @@ type agentMirrorPayload struct { // mirrorMessages is the agent mirror tool: it checks the source and target, // claims a row keyed by the call's client_request_id, and drives it. A retry -// with the same key returns the first row's outcome and never posts again. +// with a used key returns that row's outcome before any policy or visibility +// check, and never posts again. func (s *forgeService) mirrorMessages(ctx context.Context, caller store.AccountID, sessionID string, call *compassv1internal.ForgeCallRequest, req *compassv1internal.MirrorMessagesRequest) *compassv1internal.ForgeCallResult { topicID, messageIDs := req.GetTopicId(), req.GetMessageIds() if (topicID == "") == (len(messageIDs) == 0) { @@ -51,8 +52,17 @@ func (s *forgeService) mirrorMessages(ctx context.Context, caller store.AccountI if !account.IsAgent() { return forgeErrorResult(forgeErr(connect.CodeInvalidArgument, "forge mirror: caller must be an agent")) } - rf, repo, _, fe := s.mirrorAllowed(ctx, account, store.ForgeMirrorAuto, ref) - if fe != nil { + key := call.GetClientRequestId() + if key != "" { + prior, found, err := s.mirrorStoreDB.ForgeMirrorByRequest(ctx, caller, key) + if err != nil { + return forgeErrorResult(storeForgeError(err)) + } + if found { + return s.mirrorRetryResult(ctx, prior, ref) + } + } + if _, _, _, fe := s.mirrorAllowed(ctx, account, store.ForgeMirrorAuto, ref); fe != nil { return forgeErrorResult(fe) } channelID, fe := s.mirrorSourceChannel(ctx, caller, req.GetChannel(), topicID, messageIDs) @@ -71,7 +81,6 @@ func (s *forgeService) mirrorMessages(ctx context.Context, caller store.AccountI if err != nil { return forgeErrorResult(forgeErr(connect.CodeInternal, "forge mirror: encode source payload: "+err.Error())) } - key := call.GetClientRequestId() row, created, err := s.mirrorStoreDB.ClaimForgeMirror(ctx, store.ForgeMirrorRow{ Source: 2, SourceKey: resolved.sourceKey, Actor: caller, Agent: caller, ClientRequestID: key, TargetRef: ref.String(), BodyRev: 1, State: 1, SessionID: sessionID, SourcePayload: raw, @@ -83,9 +92,9 @@ func (s *forgeService) mirrorMessages(ctx context.Context, caller store.AccountI if row.Source != 2 || row.Actor != caller || row.Agent != caller { return forgeErrorResult(forgeErr(connect.CodeFailedPrecondition, "forge mirror: source conflicts with another actor")) } - // Only the claiming call posts; a retry reports the row as it stands. + // A concurrent call claimed this key between the lookup and the claim. if key != "" && row.ClientRequestID == key { - return s.mirrorRowResult(ctx, rf, repo, ref, row, nil) + return s.mirrorRetryResult(ctx, row, ref) } if row.SourceKey != resolved.sourceKey || row.TargetRef != ref.String() { return forgeErrorResult(forgeErr(connect.CodeFailedPrecondition, "forge mirror: source conflicts with another mirror")) @@ -95,7 +104,16 @@ func (s *forgeService) mirrorMessages(ctx context.Context, caller store.AccountI if !ok { return forgeErrorResult(forgeErr(connect.CodeUnavailable, "forge mirror: still delivering")) } - return s.mirrorRowResult(ctx, rf, repo, ref, result.row, result.err) + return s.mirrorRowResult(ctx, result.row, result.err) +} + +// mirrorRetryResult reports the row a used key reached. A key reused for a +// different target is refused, so a key never reports another target's comment. +func (s *forgeService) mirrorRetryResult(ctx context.Context, row store.ForgeMirrorRow, ref refs.Ref) *compassv1internal.ForgeCallResult { + if row.TargetRef != ref.String() { + return forgeErrorResult(forgeErr(connect.CodeFailedPrecondition, "forge mirror: client_request_id was used for another target")) + } + return s.mirrorRowResult(ctx, row, nil) } // mirrorSourceChannel returns the channel holding the source. A named channel @@ -172,11 +190,12 @@ func (s *forgeService) agentMessagesBodyForRow() mirror.BodyFunc { } // mirrorRowResult maps a row to the tool result: its comment once done, the -// stored error once failed, and unavailable while it is still pending. -func (s *forgeService) mirrorRowResult(ctx context.Context, rf resolvedForge, repo string, ref refs.Ref, row store.ForgeMirrorRow, driveErr error) *compassv1internal.ForgeCallResult { +// stored error once failed, and unavailable while it is still pending. The +// result arm follows the row's stored target, never the incoming request. +func (s *forgeService) mirrorRowResult(ctx context.Context, row store.ForgeMirrorRow, driveErr error) *compassv1internal.ForgeCallResult { switch row.State { case 2: - isPR, err := s.mirrorTargetIsPullRequest(ctx, rf, repo, ref) + isPR, err := s.mirrorRowTargetIsPullRequest(ctx, row) if err != nil { return forgeErrorResult(forgeErr(connect.CodeInternal, err.Error())) } @@ -186,16 +205,37 @@ func (s *forgeService) mirrorRowResult(ctx context.Context, rf resolvedForge, re } return &compassv1internal.ForgeCallResult{Result: &compassv1internal.ForgeCallResult_IssueComment{IssueComment: comment}} case 3: - code := connect.CodeFailedPrecondition - if driveErr != nil { - code = mirrorFailureCode(driveErr) - } - return forgeErrorResult(forgeErr(code, row.LastError)) + return forgeErrorResult(forgeErr(storedFailureCode(row, driveErr), row.LastError)) default: return forgeErrorResult(forgeErr(connect.CodeUnavailable, "forge mirror: still delivering")) } } +func (s *forgeService) mirrorRowTargetIsPullRequest(ctx context.Context, row store.ForgeMirrorRow) (bool, error) { + ref, err := refs.Parse(row.TargetRef) + if err != nil { + return false, fmt.Errorf("mirror: parse stored target %q: %w", row.TargetRef, err) + } + rf, repo, _, fe := s.resolveMirrorCoordinate(ref) + if fe != nil { + return false, fmt.Errorf("mirror: resolve stored target %q: %s", row.TargetRef, fe.GetMessage()) + } + return s.mirrorTargetIsPullRequest(ctx, rf, repo, ref) +} + +// storedFailureCode is the code the failing drive stored with the row. With +// none stored it falls back to the drive error, then to failed_precondition. +func storedFailureCode(row store.ForgeMirrorRow, driveErr error) connect.Code { + var code connect.Code + if row.LastErrorCode != "" && code.UnmarshalText([]byte(row.LastErrorCode)) == nil { + return code + } + if driveErr != nil { + return mirrorFailureCode(driveErr) + } + return connect.CodeFailedPrecondition +} + // mirrorSourceError maps a source lookup error; every not-found collapses to // one message so an unknown and an invisible source are indistinguishable. func mirrorSourceError(err error) *compassv1internal.ForgeCallError { diff --git a/go/server/forge_mirror_agent_pgtest_test.go b/go/server/forge_mirror_agent_pgtest_test.go index a638217eb..a4dec6c62 100644 --- a/go/server/forge_mirror_agent_pgtest_test.go +++ b/go/server/forge_mirror_agent_pgtest_test.go @@ -290,67 +290,207 @@ func TestMirrorMessagesChannelNameDerivesOrMustMatch(t *testing.T) { } // A row left pending by a server that stopped before driving it is redriven -// by a fresh service and store handle: same body, stored session. +// by a fresh service and store handle: same body, stored session. The first +// render is taken from a live twin row so the bodies can be compared whole. func TestMirrorMessagesRedriveAfterRestartUsesStoredPayloadAndSession(t *testing.T) { - f := newAgentMirrorFixture(t) - f.svc.StopMirrorLifetime() - pending := f.mirror(t, f.svc, "call-restart", &compassv1internal.MirrorMessagesRequest{Ref: "owner/auto#12", TopicId: f.topicID, Note: "Kept across restart."}) - if pending.GetError().GetCode() != connect.CodeUnavailable.String() || len(f.author.Calls()) != 0 { - t.Fatalf("closing-server result = %v, calls=%d; want unavailable and no post", pending, len(f.author.Calls())) + for _, tc := range []struct { + name string + req func(f *agentMirrorFixture, ref string) *compassv1internal.MirrorMessagesRequest + }{ + {"topic payload", func(f *agentMirrorFixture, ref string) *compassv1internal.MirrorMessagesRequest { + return &compassv1internal.MirrorMessagesRequest{Ref: ref, TopicId: f.topicID, Note: "Kept across restart."} + }}, + {"message id payload", func(f *agentMirrorFixture, ref string) *compassv1internal.MirrorMessagesRequest { + return &compassv1internal.MirrorMessagesRequest{Ref: ref, MessageIds: []string{f.msgIDs[1], f.msgIDs[0]}, Note: "Kept across restart."} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newAgentMirrorFixture(t) + live := f.mirror(t, f.svc, "call-live", tc.req(f, "owner/auto#12")) + if live.GetIssueComment() == nil || len(f.author.Calls()) != 1 { + t.Fatalf("live result = %v, want one posted comment", live) + } + f.svc.StopMirrorLifetime() + pending := f.mirror(t, f.svc, "call-restart", tc.req(f, "owner/auto#14")) + if pending.GetError().GetCode() != connect.CodeUnavailable.String() || len(f.author.Calls()) != 1 { + t.Fatalf("closing-server result = %v, calls=%d; want unavailable and no new post", pending, len(f.author.Calls())) + } + fresh := f.service(t, openAgentMirrorStore(t, f.dsn)) + fresh.DriveAgent(f.ctx, f.agent.ID) + calls := f.author.Calls() + if len(calls) != 2 { + t.Fatalf("provider calls after redrive = %+v, want one more post", calls) + } + // Each body ends with its own row marker; everything before it is the render. + first, redriven := stripMirrorMarker(calls[0].Body), stripMirrorMarker(calls[1].Body) + if redriven != first { + t.Fatalf("redriven body =\n%s\nwant the first render\n%s", redriven, first) + } + if !strings.Contains(redriven, "session="+agentMirrorSession) || !strings.Contains(redriven, "Kept across restart.") { + t.Fatalf("redriven body = %q, want the stored session and note", redriven) + } + if rows, err := fresh.mirrorStoreDB.OpenForgeMirrors(f.ctx, f.agent.ID); err != nil || len(rows) != 0 { + t.Fatalf("open rows after redrive = (%+v, %v), want none", rows, err) + } + }) } - fresh := f.service(t, openAgentMirrorStore(t, f.dsn)) - fresh.DriveAgent(f.ctx, f.agent.ID) - calls := f.author.Calls() - if len(calls) != 1 { - t.Fatalf("provider calls after redrive = %+v, want one post", calls) +} + +func stripMirrorMarker(body string) string { + if i := strings.LastIndex(body, "