From a676404eee56baa5fac3ca51289261fe49941aa7 Mon Sep 17 00:00:00 2001 From: mintaka Date: Mon, 12 Oct 2026 17:06:46 -0400 Subject: [PATCH 1/3] feat(board): agent board RPCs, roster summary and board events (RIG-5172) Add UpdateAgentBoard and ListAgentBoards to CommsService plus the agent relay arms. The roster carries a per-agent board summary, and AgentBoardChanged fans out under the board read visibility clip. Co-authored-by: Matt Wilkinson --- docs/specs/product/compass.md | 49 +- go/gen/compass/v1/comms.pb.go | 80 +- .../v1/compassv1connect/comms.connect.go | 45 +- go/internal/auth/admin_gate.go | 3 +- go/internal/auth/admin_gate_test.go | 2 + go/internal/board/deliver_integration_test.go | 21 +- go/internal/board/export_test.go | 24 + go/internal/comms/board.go | 650 ++++++++++++++++ go/internal/comms/board_limits_test.go | 177 +++++ go/internal/comms/board_pgtest_test.go | 706 ++++++++++++++++++ go/internal/comms/comms.go | 7 + go/internal/comms/roster.go | 70 +- go/internal/comms/roster_board_pgtest_test.go | 210 ++++++ go/internal/comms/subscribe.go | 15 +- .../comms/subscribe_failclosed_test.go | 9 + .../gen/compass/v1/agent_gateway.pb.go | 273 ++++--- go/internal/runnerhub/helpers_test.go | 34 +- go/internal/runnerhub/hub.go | 3 + .../runnerhub/relay_arm_coverage_test.go | 68 +- go/internal/runnerhub/relay_comms.go | 18 +- go/internal/store/agent_boards.go | 330 ++++++-- go/internal/store/agent_boards_pgtest_test.go | 154 +++- go/internal/store/db/agent_boards.sql.go | 23 +- go/internal/store/queries/agent_boards.sql | 24 +- go/server/serve.go | 6 + .../src/gen/compass/v1/agent_gateway_pb.ts | 33 +- .../src/gen/compass/v1/comms_pb.ts | 28 +- .../src/gen/compass/v1/comms_pb.ts | 28 +- proto/compass/v1/agent_gateway.proto | 9 +- proto/compass/v1/comms.proto | 18 +- 30 files changed, 2796 insertions(+), 321 deletions(-) create mode 100644 go/internal/board/export_test.go create mode 100644 go/internal/comms/board.go create mode 100644 go/internal/comms/board_limits_test.go create mode 100644 go/internal/comms/board_pgtest_test.go create mode 100644 go/internal/comms/roster_board_pgtest_test.go diff --git a/docs/specs/product/compass.md b/docs/specs/product/compass.md index 5bab04a35..95cedd412 100644 --- a/docs/specs/product/compass.md +++ b/docs/specs/product/compass.md @@ -704,7 +704,36 @@ pipeline. (`CreateChannelGroup`, `ListChannelGroups`, `ListChannels`, `CreateChannel`, `UpdateChannelMembers`), the agent-workspace projection (`OpenAgentWorkspace`), the message surface (`ListMessages`, `PostMessage`, `RespondToAsk`, -`SearchMessages`), and the comms event stream (`SubscribeComms`). +`SearchMessages`), roster reads (`GetRoster`, `ListAgentBoards`), and the comms +event stream (`SubscribeComms`). Agents write their own board through the +agent-gateway `update_board` relay arm, not a CommsService RPC. + +### Requirement: An agent writes its own board with a presence-based patch + +An agent writes only its own board, through the agent-gateway `update_board` +arm; a non-agent caller gets `permission_denied`. In each `upsert_tasks` +entry an unset field keeps the stored value and a set field (including `""`) +replaces it. A present `refs` or `waiting` op replaces the list, and an empty +op clears it. A new task needs a `title`, and its `state` defaults to `ACTIVE`. +`remove_task_keys` drops tasks (removing the focus task clears the focus), and +`halt` and `clear_halt` are mutually exclusive. An `automatic` patch never +refreshes the board's written turn or time; an empty one returns the board +unchanged and emits no event. A committed write emits `AgentBoardChanged` with +the authored fields only. + +### Requirement: `ListAgentBoards` returns visible boards and derived refs + +`ListAgentBoards` SHALL return each matching agent's whole board when the +caller can see that agent, with the matching task keys and derived refs. The +request's optional handle, text, ref, waiting, state, and halt filters SHALL be +combined with AND; the default limit is 20 and the maximum is 100. + +#### Scenario: A peer lists a matching board + +- **Given** an agent board visible to the caller and a task matching the query +- **When** the caller lists boards with that query +- **Then** the response contains the whole board and identifies the matching + task key. ### Requirement: The caller is the connection's account, never a request field @@ -830,11 +859,16 @@ shared-grouped channel — (as `ListChannels`), not bare membership, so a shared channel's change still reaches a non-member who may see it; a `ChannelGroupChanged` by group visibility (as `ListChannelGroups`); an `AccountChanged` by account visibility (as `ListAccounts`); an -`AgentWorkspaceChanged` by the agent's home-channel membership. A +`AgentWorkspaceChanged` by the agent's home-channel membership; an +`AgentBoardChanged` by board visibility (as `ListAgentBoards`). A `CommsResyncRequired` is a control frame and is always delivered. A non-visible event is silently skipped, not a resync; a failure to resolve visibility ends the stream rather than delivering an unfiltered event. +`AgentBoardChanged` follows the same visibility split as `ListAgentBoards`. Its +board contains authored fields only, with no asks or derived refs; clients +re-read the list RPC for those values. + Because a subscriber's live tail is filtered, it observes gaps in the bus `seq` sequence (an event dropped for a channel it cannot see). A gap is normal: the client SHALL treat it as such and resume from its last-seen `seq`, never as a @@ -1428,13 +1462,12 @@ Runner SHALL use the system roots. ## Not yet specified -These surfaces are designed (see the design record) but not yet implemented, and -are out of scope for this spec until they land: +These remaining surfaces are designed (see the design record) but not yet +implemented, and are out of scope for this spec until they land: -- **Dispatcher and board/audit events** — the agent session layer above plugs a - Dispatcher MCP endpoint into a named seam, but its logic is a separate issue; - the board and audit event payloads are likewise unbuilt. The Bridge UI that - consumes the agent event stream is out of scope. +- **Dispatcher and audit events** — the agent session layer above plugs a + Dispatcher MCP endpoint into a named seam, but its logic is a separate issue. + The Bridge UI that consumes the agent event stream is out of scope. - **Desktop shell (Wails v3)** — a thin shell bridging the UI webview to the server's Unix socket over its own IPC (a browser cannot dial a Unix socket), streaming gRPC-Web responses back as ordered byte frames with no localhost TCP diff --git a/go/gen/compass/v1/comms.pb.go b/go/gen/compass/v1/comms.pb.go index fd0a24841..23a90d5dd 100644 --- a/go/gen/compass/v1/comms.pb.go +++ b/go/gen/compass/v1/comms.pb.go @@ -2933,7 +2933,7 @@ type BoardWaiting struct { What string `protobuf:"bytes,3,opt,name=what,proto3" json:"what,omitempty"` // The related issue or pull request reference. Ref string `protobuf:"bytes,4,opt,name=ref,proto3" json:"ref,omitempty"` - // Task keys blocked by this wait. + // References blocked by this wait. Blocks []string `protobuf:"bytes,5,rep,name=blocks,proto3" json:"blocks,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -4292,7 +4292,7 @@ type ListAgentBoardsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Restricts results to these agent handles when non-empty. AgentHandles []string `protobuf:"bytes,1,rep,name=agent_handles,json=agentHandles,proto3" json:"agent_handles,omitempty"` - // Searches board content for this query. + // Searches task titles and notes for this query. Query string `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"` // Restricts results to boards containing this reference. Ref string `protobuf:"bytes,3,opt,name=ref,proto3" json:"ref,omitempty"` @@ -4433,12 +4433,13 @@ func (x *ListAgentBoardsResponse) GetBoards() []*AgentBoard { return nil } -// A live agent-board update; the event's board has no asks. +// An authored-only board update. Clients re-read ListAgentBoards for derived refs +// and asks. type AgentBoardChanged struct { state protoimpl.MessageState `protogen:"open.v1"` // The account identifier of the agent whose board changed. AgentAccountId string `protobuf:"bytes,1,opt,name=agent_account_id,json=agentAccountId,proto3" json:"agent_account_id,omitempty"` - // The updated board with asks omitted. + // The authored board; clients re-read ListAgentBoards for derived refs and asks. Board *AgentBoard `protobuf:"bytes,2,opt,name=board,proto3" json:"board,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -8082,7 +8083,7 @@ const file_compass_v1_comms_proto_rawDesc = "" + "\vRosterScope\x12\x1d\n" + "\x19ROSTER_SCOPE_NEIGHBORHOOD\x10\x00\x12\x18\n" + "\x14ROSTER_SCOPE_SUBTREE\x10\x01\x12\x16\n" + - "\x12ROSTER_SCOPE_OWNER\x10\x022\xc0\x11\n" + + "\x12ROSTER_SCOPE_OWNER\x10\x022\x9c\x12\n" + "\fCommsService\x12K\n" + "\n" + "CreateUser\x12\x1d.compass.v1.CreateUserRequest\x1a\x1e.compass.v1.CreateUserResponse\x12N\n" + @@ -8107,7 +8108,8 @@ const file_compass_v1_comms_proto_rawDesc = "" + "\x0eSearchMessages\x12!.compass.v1.SearchMessagesRequest\x1a\".compass.v1.SearchMessagesResponse\x12]\n" + "\x10SetChannelPolicy\x12#.compass.v1.SetChannelPolicyRequest\x1a$.compass.v1.SetChannelPolicyResponse\x12H\n" + "\tGetRoster\x12\x1c.compass.v1.GetRosterRequest\x1a\x1d.compass.v1.GetRosterResponse\x12`\n" + - "\x11UpdatePinnedBoard\x12$.compass.v1.UpdatePinnedBoardRequest\x1a%.compass.v1.UpdatePinnedBoardResponse\x12N\n" + + "\x11UpdatePinnedBoard\x12$.compass.v1.UpdatePinnedBoardRequest\x1a%.compass.v1.UpdatePinnedBoardResponse\x12Z\n" + + "\x0fListAgentBoards\x12\".compass.v1.ListAgentBoardsRequest\x1a#.compass.v1.ListAgentBoardsResponse\x12N\n" + "\vApprovePeer\x12\x1e.compass.v1.ApprovePeerRequest\x1a\x1f.compass.v1.ApprovePeerResponse\x12K\n" + "\n" + "RevokePeer\x12\x1d.compass.v1.RevokePeerRequest\x1a\x1e.compass.v1.RevokePeerResponse\x12H\n" + @@ -8357,38 +8359,40 @@ var file_compass_v1_comms_proto_depIdxs = []int32{ 86, // 112: compass.v1.CommsService.SetChannelPolicy:input_type -> compass.v1.SetChannelPolicyRequest 88, // 113: compass.v1.CommsService.GetRoster:input_type -> compass.v1.GetRosterRequest 91, // 114: compass.v1.CommsService.UpdatePinnedBoard:input_type -> compass.v1.UpdatePinnedBoardRequest - 58, // 115: compass.v1.CommsService.ApprovePeer:input_type -> compass.v1.ApprovePeerRequest - 60, // 116: compass.v1.CommsService.RevokePeer:input_type -> compass.v1.RevokePeerRequest - 62, // 117: compass.v1.CommsService.ListPeers:input_type -> compass.v1.ListPeersRequest - 109, // 118: compass.v1.CommsService.SubscribeComms:input_type -> compass.v1.SubscribeCommsRequest - 65, // 119: compass.v1.CommsService.CreateUser:output_type -> compass.v1.CreateUserResponse - 67, // 120: compass.v1.CommsService.CreateAgent:output_type -> compass.v1.CreateAgentResponse - 69, // 121: compass.v1.CommsService.ListAccounts:output_type -> compass.v1.ListAccountsResponse - 71, // 122: compass.v1.CommsService.CreateChannelGroup:output_type -> compass.v1.CreateChannelGroupResponse - 73, // 123: compass.v1.CommsService.ListChannelGroups:output_type -> compass.v1.ListChannelGroupsResponse - 75, // 124: compass.v1.CommsService.ListChannels:output_type -> compass.v1.ListChannelsResponse - 77, // 125: compass.v1.CommsService.CreateChannel:output_type -> compass.v1.CreateChannelResponse - 79, // 126: compass.v1.CommsService.UpdateChannelMembers:output_type -> compass.v1.UpdateChannelMembersResponse - 81, // 127: compass.v1.CommsService.OpenDM:output_type -> compass.v1.OpenDMResponse - 83, // 128: compass.v1.CommsService.ReparentAgent:output_type -> compass.v1.ReparentAgentResponse - 85, // 129: compass.v1.CommsService.ReparentChannel:output_type -> compass.v1.ReparentChannelResponse - 95, // 130: compass.v1.CommsService.OpenAgentWorkspace:output_type -> compass.v1.OpenAgentWorkspaceResponse - 97, // 131: compass.v1.CommsService.ListMessages:output_type -> compass.v1.ListMessagesResponse - 99, // 132: compass.v1.CommsService.PostMessage:output_type -> compass.v1.PostMessageResponse - 101, // 133: compass.v1.CommsService.ListTopics:output_type -> compass.v1.ListTopicsResponse - 103, // 134: compass.v1.CommsService.UpdateTopic:output_type -> compass.v1.UpdateTopicResponse - 106, // 135: compass.v1.CommsService.RespondToAsk:output_type -> compass.v1.RespondToAskResponse - 49, // 136: compass.v1.CommsService.MirrorToForge:output_type -> compass.v1.MirrorToForgeResponse - 108, // 137: compass.v1.CommsService.SearchMessages:output_type -> compass.v1.SearchMessagesResponse - 87, // 138: compass.v1.CommsService.SetChannelPolicy:output_type -> compass.v1.SetChannelPolicyResponse - 89, // 139: compass.v1.CommsService.GetRoster:output_type -> compass.v1.GetRosterResponse - 93, // 140: compass.v1.CommsService.UpdatePinnedBoard:output_type -> compass.v1.UpdatePinnedBoardResponse - 59, // 141: compass.v1.CommsService.ApprovePeer:output_type -> compass.v1.ApprovePeerResponse - 61, // 142: compass.v1.CommsService.RevokePeer:output_type -> compass.v1.RevokePeerResponse - 63, // 143: compass.v1.CommsService.ListPeers:output_type -> compass.v1.ListPeersResponse - 29, // 144: compass.v1.CommsService.SubscribeComms:output_type -> compass.v1.SubscribeCommsResponse - 119, // [119:145] is the sub-list for method output_type - 93, // [93:119] is the sub-list for method input_type + 54, // 115: compass.v1.CommsService.ListAgentBoards:input_type -> compass.v1.ListAgentBoardsRequest + 58, // 116: compass.v1.CommsService.ApprovePeer:input_type -> compass.v1.ApprovePeerRequest + 60, // 117: compass.v1.CommsService.RevokePeer:input_type -> compass.v1.RevokePeerRequest + 62, // 118: compass.v1.CommsService.ListPeers:input_type -> compass.v1.ListPeersRequest + 109, // 119: compass.v1.CommsService.SubscribeComms:input_type -> compass.v1.SubscribeCommsRequest + 65, // 120: compass.v1.CommsService.CreateUser:output_type -> compass.v1.CreateUserResponse + 67, // 121: compass.v1.CommsService.CreateAgent:output_type -> compass.v1.CreateAgentResponse + 69, // 122: compass.v1.CommsService.ListAccounts:output_type -> compass.v1.ListAccountsResponse + 71, // 123: compass.v1.CommsService.CreateChannelGroup:output_type -> compass.v1.CreateChannelGroupResponse + 73, // 124: compass.v1.CommsService.ListChannelGroups:output_type -> compass.v1.ListChannelGroupsResponse + 75, // 125: compass.v1.CommsService.ListChannels:output_type -> compass.v1.ListChannelsResponse + 77, // 126: compass.v1.CommsService.CreateChannel:output_type -> compass.v1.CreateChannelResponse + 79, // 127: compass.v1.CommsService.UpdateChannelMembers:output_type -> compass.v1.UpdateChannelMembersResponse + 81, // 128: compass.v1.CommsService.OpenDM:output_type -> compass.v1.OpenDMResponse + 83, // 129: compass.v1.CommsService.ReparentAgent:output_type -> compass.v1.ReparentAgentResponse + 85, // 130: compass.v1.CommsService.ReparentChannel:output_type -> compass.v1.ReparentChannelResponse + 95, // 131: compass.v1.CommsService.OpenAgentWorkspace:output_type -> compass.v1.OpenAgentWorkspaceResponse + 97, // 132: compass.v1.CommsService.ListMessages:output_type -> compass.v1.ListMessagesResponse + 99, // 133: compass.v1.CommsService.PostMessage:output_type -> compass.v1.PostMessageResponse + 101, // 134: compass.v1.CommsService.ListTopics:output_type -> compass.v1.ListTopicsResponse + 103, // 135: compass.v1.CommsService.UpdateTopic:output_type -> compass.v1.UpdateTopicResponse + 106, // 136: compass.v1.CommsService.RespondToAsk:output_type -> compass.v1.RespondToAskResponse + 49, // 137: compass.v1.CommsService.MirrorToForge:output_type -> compass.v1.MirrorToForgeResponse + 108, // 138: compass.v1.CommsService.SearchMessages:output_type -> compass.v1.SearchMessagesResponse + 87, // 139: compass.v1.CommsService.SetChannelPolicy:output_type -> compass.v1.SetChannelPolicyResponse + 89, // 140: compass.v1.CommsService.GetRoster:output_type -> compass.v1.GetRosterResponse + 93, // 141: compass.v1.CommsService.UpdatePinnedBoard:output_type -> compass.v1.UpdatePinnedBoardResponse + 55, // 142: compass.v1.CommsService.ListAgentBoards:output_type -> compass.v1.ListAgentBoardsResponse + 59, // 143: compass.v1.CommsService.ApprovePeer:output_type -> compass.v1.ApprovePeerResponse + 61, // 144: compass.v1.CommsService.RevokePeer:output_type -> compass.v1.RevokePeerResponse + 63, // 145: compass.v1.CommsService.ListPeers:output_type -> compass.v1.ListPeersResponse + 29, // 146: compass.v1.CommsService.SubscribeComms:output_type -> compass.v1.SubscribeCommsResponse + 120, // [120:147] is the sub-list for method output_type + 93, // [93:120] is the sub-list for method input_type 93, // [93:93] is the sub-list for extension type_name 93, // [93:93] is the sub-list for extension extendee 0, // [0:93] is the sub-list for field type_name diff --git a/go/gen/compass/v1/compassv1connect/comms.connect.go b/go/gen/compass/v1/compassv1connect/comms.connect.go index c84b3edaa..abf40a44f 100644 --- a/go/gen/compass/v1/compassv1connect/comms.connect.go +++ b/go/gen/compass/v1/compassv1connect/comms.connect.go @@ -112,6 +112,9 @@ const ( // CommsServiceUpdatePinnedBoardProcedure is the fully-qualified name of the CommsService's // UpdatePinnedBoard RPC. CommsServiceUpdatePinnedBoardProcedure = "/compass.v1.CommsService/UpdatePinnedBoard" + // CommsServiceListAgentBoardsProcedure is the fully-qualified name of the CommsService's + // ListAgentBoards RPC. + CommsServiceListAgentBoardsProcedure = "/compass.v1.CommsService/ListAgentBoards" // CommsServiceApprovePeerProcedure is the fully-qualified name of the CommsService's ApprovePeer // RPC. CommsServiceApprovePeerProcedure = "/compass.v1.CommsService/ApprovePeer" @@ -207,6 +210,8 @@ type CommsServiceClient interface { // minting; the message must already live in a topic of the channel). A pin // may compare-and-swap a currently pinned entry. Emits ChannelChanged. UpdatePinnedBoard(context.Context, *connect.Request[v1.UpdatePinnedBoardRequest]) (*connect.Response[v1.UpdatePinnedBoardResponse], error) + // List whole boards for agents the caller can see, optionally filtered. + ListAgentBoards(context.Context, *connect.Request[v1.ListAgentBoardsRequest]) (*connect.Response[v1.ListAgentBoardsResponse], error) // Approve a user as a peer of the caller (a user account). A peering is // live only when both users have approved each other; until then this is // a pending request. Idempotent. peer_handle is a bare user handle; @@ -218,10 +223,9 @@ type CommsServiceClient interface { RevokePeer(context.Context, *connect.Request[v1.RevokePeerRequest]) (*connect.Response[v1.RevokePeerResponse], error) // List the caller's peerings: every user with an approval in either direction. ListPeers(context.Context, *connect.Request[v1.ListPeersRequest]) (*connect.Response[v1.ListPeersResponse], error) - // The event stream: message, channel, group, account, and workspace updates, - // scoped to the caller's visible set, with gap-free replay-on-join. The sole - // push path for the comms surface, mirroring CompassService.SubscribeEvents' - // seq/epoch replay contract (D1). + // The comms event stream, scoped to the caller's visible set, with gap-free + // replay-on-join. The sole push path for the comms surface, mirroring + // CompassService.SubscribeEvents' seq/epoch replay contract (D1). SubscribeComms(context.Context, *connect.Request[v1.SubscribeCommsRequest]) (*connect.ServerStreamForClient[v1.SubscribeCommsResponse], error) } @@ -368,6 +372,12 @@ func NewCommsServiceClient(httpClient connect.HTTPClient, baseURL string, opts . connect.WithSchema(commsServiceMethods.ByName("UpdatePinnedBoard")), connect.WithClientOptions(opts...), ), + listAgentBoards: connect.NewClient[v1.ListAgentBoardsRequest, v1.ListAgentBoardsResponse]( + httpClient, + baseURL+CommsServiceListAgentBoardsProcedure, + connect.WithSchema(commsServiceMethods.ByName("ListAgentBoards")), + connect.WithClientOptions(opts...), + ), approvePeer: connect.NewClient[v1.ApprovePeerRequest, v1.ApprovePeerResponse]( httpClient, baseURL+CommsServiceApprovePeerProcedure, @@ -419,6 +429,7 @@ type commsServiceClient struct { setChannelPolicy *connect.Client[v1.SetChannelPolicyRequest, v1.SetChannelPolicyResponse] getRoster *connect.Client[v1.GetRosterRequest, v1.GetRosterResponse] updatePinnedBoard *connect.Client[v1.UpdatePinnedBoardRequest, v1.UpdatePinnedBoardResponse] + listAgentBoards *connect.Client[v1.ListAgentBoardsRequest, v1.ListAgentBoardsResponse] approvePeer *connect.Client[v1.ApprovePeerRequest, v1.ApprovePeerResponse] revokePeer *connect.Client[v1.RevokePeerRequest, v1.RevokePeerResponse] listPeers *connect.Client[v1.ListPeersRequest, v1.ListPeersResponse] @@ -535,6 +546,11 @@ func (c *commsServiceClient) UpdatePinnedBoard(ctx context.Context, req *connect return c.updatePinnedBoard.CallUnary(ctx, req) } +// ListAgentBoards calls compass.v1.CommsService.ListAgentBoards. +func (c *commsServiceClient) ListAgentBoards(ctx context.Context, req *connect.Request[v1.ListAgentBoardsRequest]) (*connect.Response[v1.ListAgentBoardsResponse], error) { + return c.listAgentBoards.CallUnary(ctx, req) +} + // ApprovePeer calls compass.v1.CommsService.ApprovePeer. func (c *commsServiceClient) ApprovePeer(ctx context.Context, req *connect.Request[v1.ApprovePeerRequest]) (*connect.Response[v1.ApprovePeerResponse], error) { return c.approvePeer.CallUnary(ctx, req) @@ -638,6 +654,8 @@ type CommsServiceHandler interface { // minting; the message must already live in a topic of the channel). A pin // may compare-and-swap a currently pinned entry. Emits ChannelChanged. UpdatePinnedBoard(context.Context, *connect.Request[v1.UpdatePinnedBoardRequest]) (*connect.Response[v1.UpdatePinnedBoardResponse], error) + // List whole boards for agents the caller can see, optionally filtered. + ListAgentBoards(context.Context, *connect.Request[v1.ListAgentBoardsRequest]) (*connect.Response[v1.ListAgentBoardsResponse], error) // Approve a user as a peer of the caller (a user account). A peering is // live only when both users have approved each other; until then this is // a pending request. Idempotent. peer_handle is a bare user handle; @@ -649,10 +667,9 @@ type CommsServiceHandler interface { RevokePeer(context.Context, *connect.Request[v1.RevokePeerRequest]) (*connect.Response[v1.RevokePeerResponse], error) // List the caller's peerings: every user with an approval in either direction. ListPeers(context.Context, *connect.Request[v1.ListPeersRequest]) (*connect.Response[v1.ListPeersResponse], error) - // The event stream: message, channel, group, account, and workspace updates, - // scoped to the caller's visible set, with gap-free replay-on-join. The sole - // push path for the comms surface, mirroring CompassService.SubscribeEvents' - // seq/epoch replay contract (D1). + // The comms event stream, scoped to the caller's visible set, with gap-free + // replay-on-join. The sole push path for the comms surface, mirroring + // CompassService.SubscribeEvents' seq/epoch replay contract (D1). SubscribeComms(context.Context, *connect.Request[v1.SubscribeCommsRequest], *connect.ServerStream[v1.SubscribeCommsResponse]) error } @@ -795,6 +812,12 @@ func NewCommsServiceHandler(svc CommsServiceHandler, opts ...connect.HandlerOpti connect.WithSchema(commsServiceMethods.ByName("UpdatePinnedBoard")), connect.WithHandlerOptions(opts...), ) + commsServiceListAgentBoardsHandler := connect.NewUnaryHandler( + CommsServiceListAgentBoardsProcedure, + svc.ListAgentBoards, + connect.WithSchema(commsServiceMethods.ByName("ListAgentBoards")), + connect.WithHandlerOptions(opts...), + ) commsServiceApprovePeerHandler := connect.NewUnaryHandler( CommsServiceApprovePeerProcedure, svc.ApprovePeer, @@ -865,6 +888,8 @@ func NewCommsServiceHandler(svc CommsServiceHandler, opts ...connect.HandlerOpti commsServiceGetRosterHandler.ServeHTTP(w, r) case CommsServiceUpdatePinnedBoardProcedure: commsServiceUpdatePinnedBoardHandler.ServeHTTP(w, r) + case CommsServiceListAgentBoardsProcedure: + commsServiceListAgentBoardsHandler.ServeHTTP(w, r) case CommsServiceApprovePeerProcedure: commsServiceApprovePeerHandler.ServeHTTP(w, r) case CommsServiceRevokePeerProcedure: @@ -970,6 +995,10 @@ func (UnimplementedCommsServiceHandler) UpdatePinnedBoard(context.Context, *conn return nil, connect.NewError(connect.CodeUnimplemented, errors.New("compass.v1.CommsService.UpdatePinnedBoard is not implemented")) } +func (UnimplementedCommsServiceHandler) ListAgentBoards(context.Context, *connect.Request[v1.ListAgentBoardsRequest]) (*connect.Response[v1.ListAgentBoardsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("compass.v1.CommsService.ListAgentBoards is not implemented")) +} + func (UnimplementedCommsServiceHandler) ApprovePeer(context.Context, *connect.Request[v1.ApprovePeerRequest]) (*connect.Response[v1.ApprovePeerResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("compass.v1.CommsService.ApprovePeer is not implemented")) } diff --git a/go/internal/auth/admin_gate.go b/go/internal/auth/admin_gate.go index e6598203c..5e344f684 100644 --- a/go/internal/auth/admin_gate.go +++ b/go/internal/auth/admin_gate.go @@ -117,7 +117,8 @@ func classifyProcedure(procedure string) (privilege, bool) { compassv1connect.CommsServiceApprovePeerProcedure, compassv1connect.CommsServiceRevokePeerProcedure, compassv1connect.CommsServiceListPeersProcedure, - compassv1connect.CommsServiceSubscribeCommsProcedure: + compassv1connect.CommsServiceSubscribeCommsProcedure, + compassv1connect.CommsServiceListAgentBoardsProcedure: return authenticatedOpen{}, true // The SecretsService write + read RPCs: open to any authenticated account. The diff --git a/go/internal/auth/admin_gate_test.go b/go/internal/auth/admin_gate_test.go index 939429589..9e4cc03fa 100644 --- a/go/internal/auth/admin_gate_test.go +++ b/go/internal/auth/admin_gate_test.go @@ -151,6 +151,7 @@ func TestAdminGateAllowsAnyAccountOnOpenRPCs(t *testing.T) { {"CommsListPeers", compassv1connect.CommsServiceListPeersProcedure}, {"CommsPostMessage", compassv1connect.CommsServicePostMessageProcedure}, {"CommsSubscribeComms", compassv1connect.CommsServiceSubscribeCommsProcedure}, + {"CommsListAgentBoards", compassv1connect.CommsServiceListAgentBoardsProcedure}, } for _, tc := range open { @@ -202,6 +203,7 @@ func TestClassifyProcedureClassifiesKnownAndUnknownProcedures(t *testing.T) { {"CommsListPeers", compassv1connect.CommsServiceListPeersProcedure, false, true}, {"CommsReparentChannel", compassv1connect.CommsServiceReparentChannelProcedure, false, true}, {"CommsPostMessage", compassv1connect.CommsServicePostMessageProcedure, false, true}, + {"CommsListAgentBoards", compassv1connect.CommsServiceListAgentBoardsProcedure, false, true}, {"CommsSubscribeComms", compassv1connect.CommsServiceSubscribeCommsProcedure, false, true}, {"SkipBatchWindow", compassv1connect.CompassServiceSkipBatchWindowProcedure, false, true}, {"unknown compass method", "/compass.v1.CompassService/Bogus", true, false}, diff --git a/go/internal/board/deliver_integration_test.go b/go/internal/board/deliver_integration_test.go index 55f99bbfa..580ebe79e 100644 --- a/go/internal/board/deliver_integration_test.go +++ b/go/internal/board/deliver_integration_test.go @@ -1,6 +1,6 @@ //go:build unix -package board +package board_test // Integration seam between the RunnerHub and the real Bridge board: a session // lifecycle frame through the REAL hub is both recorded into the board and @@ -17,6 +17,7 @@ import ( "connectrpc.com/otelconnect" compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/board" compassv1internal "github.com/RigelBuild/compass/go/internal/gen/compass/v1" "github.com/RigelBuild/compass/go/internal/gen/compass/v1/compassv1internalconnect" "github.com/RigelBuild/compass/go/internal/runnerhub" @@ -77,7 +78,7 @@ func stateFrame(state compassv1.AgentSessionState) *compassv1internal.AgentFrame // stopped fanning would starve the bus subscription. Each half reddens // independently. func TestDeliverStateFrameRecordsAndFans(t *testing.T) { - brd, bus := newBoard(t) + brd, bus := board.NewBoardForTest(t) hub := runnerhub.NewHub(brd, noopTailSink{}, nil, nil) enrollHub(t, hub) @@ -91,20 +92,20 @@ func TestDeliverStateFrameRecordsAndFans(t *testing.T) { RunnerID: testRunnerID, RunnerSeq: 1, SessionID: "s1", - Frame: stateFrame(working), + Frame: stateFrame(compassv1.AgentSessionState_AGENT_SESSION_STATE_WORKING), }); err != nil { t.Fatalf("Deliver = %v, want nil", err) } // Fan-out surface: the bus subscriber saw s1/WORKING. - got := recvStatus(t, sub.Live) - if got.GetSessionId() != "s1" || got.GetState() != working { + got := board.RecvStatusForTest(t, sub.Live) + if got.GetSessionId() != "s1" || got.GetState() != compassv1.AgentSessionState_AGENT_SESSION_STATE_WORKING { t.Errorf("bus event = %s/%v, want s1/WORKING (hub must fan onto SubscribeEvents)", got.GetSessionId(), got.GetState()) } // Record surface: the board holds s1/WORKING, queryable by id. snap := brd.Snapshot("s1") - if len(snap) != 1 || snap[0].GetState() != working { + if len(snap) != 1 || snap[0].GetState() != compassv1.AgentSessionState_AGENT_SESSION_STATE_WORKING { t.Errorf("Snapshot(s1) = %v, want single s1/WORKING (hub must record into the board)", snap) } } @@ -117,7 +118,7 @@ func TestDeliverStateFrameRecordsAndFans(t *testing.T) { // would surface as a wrong first event, not a race. A regression that synthesized // an AgentSessionStatus for an UNSPECIFIED frame reddens both halves. func TestDeliverUnspecifiedFrameNeitherRecordsNorFans(t *testing.T) { - brd, bus := newBoard(t) + brd, bus := board.NewBoardForTest(t) hub := runnerhub.NewHub(brd, noopTailSink{}, nil, nil) enrollHub(t, hub) @@ -137,7 +138,7 @@ func TestDeliverUnspecifiedFrameNeitherRecordsNorFans(t *testing.T) { } if snap := brd.Snapshot("s1"); len(snap) != 0 { - t.Errorf("Snapshot(s1) = %v after UNSPECIFIED frame, want empty (nothing recorded)", idsOf(snap)) + t.Errorf("Snapshot(s1) = %v after UNSPECIFIED frame, want empty (nothing recorded)", snap) } // Sentinel: a real WORKING frame must be the first bus event. @@ -145,11 +146,11 @@ func TestDeliverUnspecifiedFrameNeitherRecordsNorFans(t *testing.T) { RunnerID: testRunnerID, RunnerSeq: 2, SessionID: "sentinel", - Frame: stateFrame(working), + Frame: stateFrame(compassv1.AgentSessionState_AGENT_SESSION_STATE_WORKING), }); err != nil { t.Fatalf("Deliver(sentinel) = %v, want nil", err) } - if got := recvStatus(t, sub.Live); got.GetSessionId() != "sentinel" { + if got := board.RecvStatusForTest(t, sub.Live); got.GetSessionId() != "sentinel" { t.Errorf("first bus event = %s, want sentinel (UNSPECIFIED frame must not fan)", got.GetSessionId()) } } diff --git a/go/internal/board/export_test.go b/go/internal/board/export_test.go new file mode 100644 index 000000000..29d4ec9bf --- /dev/null +++ b/go/internal/board/export_test.go @@ -0,0 +1,24 @@ +//go:build unix + +package board + +import ( + "testing" + + "github.com/RigelBuild/compass/go/events" + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" +) + +type BusPayload = busPayload + +// NewBoardForTest exposes the existing board test setup to external integration tests. +func NewBoardForTest(t *testing.T) (*Projection, *events.Bus[BusPayload]) { + t.Helper() + return newBoard(t) +} + +// RecvStatusForTest exposes the existing event assertion helper to external tests. +func RecvStatusForTest(t *testing.T, ch <-chan events.Stamped[BusPayload]) *compassv1.AgentSessionStatus { + t.Helper() + return recvStatus(t, ch) +} diff --git a/go/internal/comms/board.go b/go/internal/comms/board.go new file mode 100644 index 000000000..2f29844d1 --- /dev/null +++ b/go/internal/comms/board.go @@ -0,0 +1,650 @@ +//go:build unix + +package comms + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + "time" + "unicode/utf8" + + "connectrpc.com/connect" + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/board" + "github.com/RigelBuild/compass/go/internal/refs" + "github.com/RigelBuild/compass/go/internal/store" + "google.golang.org/protobuf/proto" +) + +const boardStaleAfter = 30 * time.Minute + +// UpdateBoardAsAccount validates and applies an agent patch before publishing the committed board. +func (c *Comms) UpdateBoardAsAccount( + ctx context.Context, + caller store.AccountID, + req *compassv1.UpdateAgentBoardRequest, +) (*compassv1.UpdateAgentBoardResponse, error) { + if caller == "" { + return nil, errNoActor + } + if req == nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("board update request is required")) + } + if req.GetHalt() != nil && req.GetClearHalt() { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("board update cannot set and clear halt together")) + } + patch, err := boardPatchFromWire(req) + if err != nil { + return nil, err + } + account, err := c.store.GetAccount(ctx, caller) + if err != nil { + return nil, edgeError(err) + } + if !account.IsAgent() { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("only an agent can update its board")) + } + row, err := c.store.ApplyBoardPatch(ctx, caller, patch) + if err != nil { + return nil, edgeError(err) + } + authored := boardAuthoredToWire(account, row, nil) + if authored.decodeErr != nil { + slog.ErrorContext(ctx, "comms: board decode failed after committed update", "agent_account_id", string(caller), "error", authored.decodeErr) + } + if (!patch.Automatic || !emptyBoardPatch(patch)) && c.bus != nil { + eventBoard, ok := proto.Clone(authored.wire).(*compassv1.AgentBoard) + if !ok { + slog.ErrorContext(ctx, "comms: board event clone has unexpected type", "agent_account_id", string(caller)) + } else { + c.bus.Publish(&compassv1.SubscribeCommsResponse{ + Payload: &compassv1.SubscribeCommsResponse_AgentBoardChanged{ + AgentBoardChanged: &compassv1.AgentBoardChanged{ + AgentAccountId: string(caller), + Board: eventBoard, + }, + }, + }) + } + } + wire := authored.wire + completed, completeErr := c.completeBoardWire(ctx, account, row, authored.wire, authored.refs) + if completeErr != nil { + slog.WarnContext(ctx, "comms: board refs unavailable after update", "agent_account_id", string(caller), "error", completeErr) + } else { + wire = completed + } + return &compassv1.UpdateAgentBoardResponse{Board: wire}, nil +} + +func boardPatchFromWire(req *compassv1.UpdateAgentBoardRequest) (store.BoardPatch, error) { + if len(req.GetUpsertTasks()) > store.MaxBoardTasks { + return store.BoardPatch{}, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("board task patch count exceeds maximum of %d", store.MaxBoardTasks)) + } + if len(req.GetRemoveTaskKeys()) > store.MaxBoardTasks { + return store.BoardPatch{}, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("board task removal count exceeds maximum of %d", store.MaxBoardTasks)) + } + for _, key := range req.GetRemoveTaskKeys() { + if err := validateBoardWireString("board task key", key, store.MaxBoardTaskKeyChars); err != nil { + return store.BoardPatch{}, err + } + } + patch := store.BoardPatch{Remove: req.GetRemoveTaskKeys(), Turn: req.GetTurnSequence(), Automatic: req.GetAutomatic(), NowUnixMs: time.Now().UnixMilli()} + patch.Focus = req.FocusTaskKey + patch.ClearHalt = req.GetClearHalt() + if req.FocusTaskKey != nil { + if err := validateBoardWireString("board focus task key", req.GetFocusTaskKey(), store.MaxBoardTaskKeyChars); err != nil { + return store.BoardPatch{}, err + } + } + if req.GetHalt() != nil { + halt, err := haltFromWire(req.GetHalt()) + if err != nil { + return store.BoardPatch{}, err + } + patch.Halt = &halt + } + patch.Upsert = make([]store.BoardTaskPatch, 0, len(req.GetUpsertTasks())) + for _, task := range req.GetUpsertTasks() { + if task == nil { + return store.BoardPatch{}, connect.NewError(connect.CodeInvalidArgument, errors.New("board task patch is required")) + } + row, err := boardTaskPatchFromWire(task) + if err != nil { + return store.BoardPatch{}, err + } + patch.Upsert = append(patch.Upsert, row) + } + return patch, nil +} + +func boardTaskPatchFromWire(task *compassv1.BoardTaskPatch) (store.BoardTaskPatch, error) { + if err := validateBoardTaskPatchWire(task); err != nil { + return store.BoardTaskPatch{}, err + } + var state *int16 + if task.State != nil { + value, valid := boardTaskStateToStore(task.GetState()) + if !valid { + return store.BoardTaskPatch{}, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("board task %q has invalid state %d", task.GetKey(), task.GetState())) + } + state = &value + } + subagents, err := boardSubagentsFromWire(task.GetUpsertSubagents()) + if err != nil { + return store.BoardTaskPatch{}, err + } + row := store.BoardTaskPatch{Key: task.GetKey(), Title: task.Title, Note: task.Note, UpsertSubagents: subagents, State: state} + if err := boardTaskRefsFromWire(task, &row); err != nil { + return store.BoardTaskPatch{}, err + } + if task.GetWaiting() != nil { + encoded, err := marshalBoardWaiting(task.GetWaiting().GetValues()) + if err != nil { + if _, ok := errors.AsType[*connect.Error](err); ok { + return store.BoardTaskPatch{}, err + } + return store.BoardTaskPatch{}, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("encode board waiting entries: %w", err)) + } + row.Waiting = &encoded + } + return row, nil +} + +func validateBoardTaskPatchWire(task *compassv1.BoardTaskPatch) error { + for _, field := range []struct { + name string + value string + limit int + }{ + {"board task key", task.GetKey(), store.MaxBoardTaskKeyChars}, + {"board task title", task.GetTitle(), store.MaxBoardTitleChars}, + {"board task note", task.GetNote(), store.MaxBoardNoteChars}, + } { + if err := validateBoardWireString(field.name, field.value, field.limit); err != nil { + return err + } + } + if task.GetRefs() != nil && len(task.GetRefs().GetValues()) > store.MaxBoardRefs { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("board task %q ref count exceeds maximum of %d", task.GetKey(), store.MaxBoardRefs)) + } + if task.GetWaiting() != nil && len(task.GetWaiting().GetValues()) > store.MaxBoardWaiting { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("board task %q waiting entry count exceeds maximum of %d", task.GetKey(), store.MaxBoardWaiting)) + } + if len(task.GetUpsertSubagents()) > store.MaxBoardSubagents { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("board task %q subagent count exceeds maximum of %d", task.GetKey(), store.MaxBoardSubagents)) + } + return validateBoardSubagentsWire(task.GetUpsertSubagents()) +} + +func validateBoardSubagentsWire(subagents []*compassv1.BoardSubagent) error { + for _, subagent := range subagents { + if subagent == nil { + continue + } + for _, field := range []struct { + name string + value string + limit int + }{ + {"subagent id", subagent.GetId(), store.MaxBoardSubagentIDChars}, + {"subagent agent", subagent.GetAgent(), store.MaxBoardSubagentAgentChars}, + {"subagent description", subagent.GetDescription(), store.MaxBoardSubagentDescriptionChars}, + {"subagent status", subagent.GetStatus(), store.MaxBoardSubagentStatusChars}, + } { + if err := validateBoardWireString(field.name, field.value, field.limit); err != nil { + return err + } + } + } + return nil +} + +func boardSubagentsFromWire(subagents []*compassv1.BoardSubagent) ([]byte, error) { + if len(subagents) == 0 { + return nil, nil + } + encoded, err := json.Marshal(subagents) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("encode board subagents: %w", err)) + } + return encoded, nil +} + +func boardTaskRefsFromWire(task *compassv1.BoardTaskPatch, row *store.BoardTaskPatch) error { + if task.PrimaryRef != nil { + if strings.ContainsRune(task.GetPrimaryRef(), '\x00') { + return connect.NewError(connect.CodeInvalidArgument, errors.New("board primary ref contains a NUL character")) + } + value, err := canonicalOptionalRef(task.GetPrimaryRef()) + if err != nil { + return err + } + row.PrimaryRef = &value + } + if task.GetRefs() == nil { + return nil + } + values := make([]string, 0, len(task.GetRefs().GetValues())) + for _, value := range task.GetRefs().GetValues() { + if strings.ContainsRune(value, '\x00') { + return connect.NewError(connect.CodeInvalidArgument, errors.New("board task ref contains a NUL character")) + } + canonical, err := canonicalRef(value) + if err != nil { + return err + } + values = append(values, canonical) + } + row.Refs = &values + return nil +} + +func boardTaskStateToStore(state compassv1.BoardTaskState) (int16, bool) { + switch state { + case compassv1.BoardTaskState_BOARD_TASK_STATE_ACTIVE: + return 1, true + case compassv1.BoardTaskState_BOARD_TASK_STATE_BLOCKED: + return 2, true + case compassv1.BoardTaskState_BOARD_TASK_STATE_WAITING: + return 3, true + case compassv1.BoardTaskState_BOARD_TASK_STATE_PARKED: + return 4, true + case compassv1.BoardTaskState_BOARD_TASK_STATE_DONE: + return 5, true + default: + return 0, false + } +} + +func boardWaitingFilter(value compassv1.BoardWaitingOn) int16 { + switch value { + case compassv1.BoardWaitingOn_BOARD_WAITING_ON_UNSPECIFIED: + return 0 + case compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR: + return 1 + case compassv1.BoardWaitingOn_BOARD_WAITING_ON_AGENT: + return 2 + case compassv1.BoardWaitingOn_BOARD_WAITING_ON_EXTERNAL: + return 3 + default: + return -1 + } +} + +func boardStateFilter(value compassv1.BoardTaskState) int16 { + if value == compassv1.BoardTaskState_BOARD_TASK_STATE_UNSPECIFIED { + return 0 + } + state, valid := boardTaskStateToStore(value) + if !valid { + return -1 + } + return state +} + +func boardHaltFilter(value compassv1.BoardHaltKind) int16 { + switch value { + case compassv1.BoardHaltKind_BOARD_HALT_KIND_UNSPECIFIED: + return 0 + case compassv1.BoardHaltKind_BOARD_HALT_KIND_DONE: + return 1 + case compassv1.BoardHaltKind_BOARD_HALT_KIND_BLOCKED: + return 2 + case compassv1.BoardHaltKind_BOARD_HALT_KIND_AWAITING_USER: + return 3 + default: + return -1 + } +} + +func canonicalOptionalRef(value string) (string, error) { + if value == "" { + return "", nil + } + return canonicalRef(value) +} + +func canonicalRef(value string) (string, error) { + ref, err := refs.Parse(value) + if err != nil { + return "", connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid board ref %q: %w", value, err)) + } + return ref.String(), nil +} + +// validateBoardFilterText rejects NUL before filter text reaches Postgres. +func validateBoardFilterText(req *compassv1.ListAgentBoardsRequest) error { + if strings.ContainsRune(req.GetQuery(), '\x00') || strings.ContainsRune(req.GetRef(), '\x00') { + return connect.NewError(connect.CodeInvalidArgument, errors.New("board filter contains a NUL character")) + } + for _, handle := range req.GetAgentHandles() { + if strings.ContainsRune(handle, '\x00') { + return connect.NewError(connect.CodeInvalidArgument, errors.New("board filter contains a NUL character")) + } + } + return nil +} + +func validateBoardWireString(field, value string, maxChars int) error { + if strings.ContainsRune(value, '\x00') { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("%s contains a NUL character", field)) + } + if utf8.RuneCountInString(value) > maxChars { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("%s exceeds maximum of %d characters", field, maxChars)) + } + return nil +} + +func marshalBoardWaiting(values []*compassv1.BoardWaiting) ([]byte, error) { + if len(values) > store.MaxBoardWaiting { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("board task waiting entry count exceeds maximum of %d", store.MaxBoardWaiting)) + } + type waitingEntry struct { + On string `json:"on"` + AgentHandle string `json:"agent_handle,omitempty"` + What string `json:"what,omitempty"` + Ref string `json:"ref,omitempty"` + Blocks []string `json:"blocks,omitempty"` + } + encoded := make([]waitingEntry, len(values)) + for i, value := range values { + if value == nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("board waiting entry is required")) + } + if len(value.GetBlocks()) > store.MaxBoardRefs { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("board waiting block count exceeds maximum of %d", store.MaxBoardRefs)) + } + if value.GetOn() < compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR || value.GetOn() > compassv1.BoardWaitingOn_BOARD_WAITING_ON_EXTERNAL { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("board waiting entry has invalid on value")) + } + if err := validateBoardWireString("waiting agent_handle", value.GetAgentHandle(), store.MaxBoardHandleChars); err != nil { + return nil, err + } + if err := validateBoardWireString("waiting what", value.GetWhat(), store.MaxBoardWhatChars); err != nil { + return nil, err + } + ref, err := canonicalOptionalRef(value.GetRef()) + if err != nil { + return nil, err + } + blocks := make([]string, len(value.GetBlocks())) + for i, blockRef := range value.GetBlocks() { + blocks[i], err = canonicalRef(blockRef) + if err != nil { + return nil, err + } + } + encoded[i] = waitingEntry{On: value.GetOn().String(), AgentHandle: value.GetAgentHandle(), What: value.GetWhat(), Ref: ref, Blocks: blocks} + } + return json.Marshal(encoded) +} + +func unmarshalBoardWaiting(raw []byte) ([]*compassv1.BoardWaiting, error) { + var values []struct { + On string `json:"on"` + AgentHandle string `json:"agent_handle"` + What string `json:"what"` + Ref string `json:"ref"` + Blocks []string `json:"blocks"` + } + if err := json.Unmarshal(raw, &values); err != nil { + return nil, err + } + out := make([]*compassv1.BoardWaiting, len(values)) + for i, value := range values { + on, ok := compassv1.BoardWaitingOn_value[value.On] + if !ok { + return nil, fmt.Errorf("unknown waiting on value %q", value.On) + } + out[i] = &compassv1.BoardWaiting{ + On: compassv1.BoardWaitingOn(on), AgentHandle: value.AgentHandle, + What: value.What, Ref: value.Ref, Blocks: value.Blocks, + } + } + return out, nil +} + +func emptyBoardPatch(patch store.BoardPatch) bool { + return len(patch.Upsert) == 0 && len(patch.Remove) == 0 && patch.Focus == nil && patch.Halt == nil && !patch.ClearHalt +} + +// ListBoardsAsAccount returns matching boards from the caller's roster clip. +func (c *Comms) ListBoardsAsAccount( + ctx context.Context, + caller store.AccountID, + req *compassv1.ListAgentBoardsRequest, +) (*compassv1.ListAgentBoardsResponse, error) { + if caller == "" { + return nil, errNoActor + } + if req == nil { + req = &compassv1.ListAgentBoardsRequest{} + } + if len(req.GetAgentHandles()) > store.MaxBoardSearchAgents { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("board agent handle count exceeds maximum of %d", store.MaxBoardSearchAgents)) + } + if err := validateBoardFilterText(req); err != nil { + return nil, err + } + if req.GetState() != compassv1.BoardTaskState_BOARD_TASK_STATE_UNSPECIFIED && boardStateFilter(req.GetState()) < 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid board state filter %d", req.GetState())) + } + if req.GetWaitingOn() != compassv1.BoardWaitingOn_BOARD_WAITING_ON_UNSPECIFIED && boardWaitingFilter(req.GetWaitingOn()) < 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid board waiting_on filter %d", req.GetWaitingOn())) + } + if req.GetHalt() != compassv1.BoardHaltKind_BOARD_HALT_KIND_UNSPECIFIED && boardHaltFilter(req.GetHalt()) < 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid board halt filter %d", req.GetHalt())) + } + handleSet := make(map[string]struct{}, min(len(req.GetAgentHandles()), store.MaxBoardSearchAgents)) + handles := make([]string, 0, min(len(req.GetAgentHandles()), store.MaxBoardSearchAgents)) + for _, handle := range req.GetAgentHandles() { + if _, found := handleSet[handle]; found { + continue + } + handleSet[handle] = struct{}{} + handles = append(handles, handle) + } + matchedAgents := make(map[store.AccountID]struct{}, len(handles)) + for _, handle := range handles { + resolved, err := c.resolveAgentAccount(ctx, caller, handle) + if errors.Is(err, store.ErrNotFound) { + continue + } + if err != nil { + return nil, edgeError(err) + } + matchedAgents[resolved.ID] = struct{}{} + } + limit := boardLimit(req.GetLimit()) + visible, err := c.store.ListAccounts(ctx, caller) + if err != nil { + return nil, edgeError(err) + } + accounts := make(map[store.AccountID]store.Account, len(visible)) + ids := make([]store.AccountID, 0, len(visible)) + for _, account := range visible { + _, matched := matchedAgents[account.ID] + if account.IsAgent() && (len(handles) == 0 || matched) { + accounts[account.ID] = account + ids = append(ids, account.ID) + } + } + ref := req.GetRef() + if ref != "" { + ref, err = canonicalRef(ref) + if err != nil { + return nil, err + } + } + if len(ids) == 0 { + return &compassv1.ListAgentBoardsResponse{}, nil + } + matches, rows, err := c.store.SearchAndReadAgentBoards(ctx, store.BoardTaskFilter{ + Agents: ids, Query: req.GetQuery(), Ref: ref, + WaitingOn: boardWaitingFilter(req.GetWaitingOn()), State: boardStateFilter(req.GetState()), + Halt: boardHaltFilter(req.GetHalt()), Limit: limit, + }) + if err != nil { + return nil, edgeError(err) + } + matchingIDs := make([]store.AccountID, 0, len(matches)) + for id := range matches { + matchingIDs = append(matchingIDs, id) + } + slices.Sort(matchingIDs) + boards := make([]*compassv1.AgentBoard, 0, len(matches)) + + for _, id := range matchingIDs { + account, ok := accounts[id] + if !ok { + continue + } + authored := boardAuthoredToWire(account, rows[id], matches[id]) + if authored.decodeErr != nil { + slog.WarnContext(ctx, "comms: board decode failed while listing", "agent_account_id", string(id), "error", authored.decodeErr) + } + wire := authored.wire + completed, err := c.completeBoardWire(ctx, account, rows[id], wire, authored.refs) + if err != nil { + slog.WarnContext(ctx, "comms: board refs unavailable while listing", "agent_account_id", string(id), "error", err) + } else { + wire = completed + } + boards = append(boards, wire) + } + return &compassv1.ListAgentBoardsResponse{Boards: boards}, nil +} + +func boardLimit(limit uint32) int { + if limit == 0 { + return 20 + } + if limit > 100 { + return 100 + } + return int(limit) +} + +// ListAgentBoards adapts the RPC request to the account-scoped board read. +func (c *Comms) ListAgentBoards( + ctx context.Context, + req *connect.Request[compassv1.ListAgentBoardsRequest], +) (*connect.Response[compassv1.ListAgentBoardsResponse], error) { + if req == nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("board list request is required")) + } + response, err := c.ListBoardsAsAccount(ctx, c.actorFromContext(ctx), req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(response), nil +} + +// boardVisible uses the same account clip as RosterAsAccount. +func (c *Comms) boardVisible(ctx context.Context, viewer, agent store.AccountID) (bool, error) { + visible, err := c.store.AccountVisibleTo(ctx, viewer, agent) + if err != nil { + return false, err + } + if !visible { + return false, nil + } + return c.store.IsAgentAccount(ctx, agent) +} + +func authoredBoardIdentity(account store.Account, row store.BoardRow, matched []string) *compassv1.AgentBoard { + return &compassv1.AgentBoard{ + AgentAccountId: string(account.ID), Handle: account.Handle, FocusTaskKey: row.Focus, + Halt: haltToWire(row.Halt), WrittenTurn: row.WrittenTurn, WrittenAtUnixMs: row.WrittenAtUnixMs, + MatchedTaskKeys: matched, Tasks: []*compassv1.BoardTask{}, + } +} + +// authoredBoard keeps a decode failure beside a usable board: callers serve the +// authored data even when some stored task fields fail to decode. +type authoredBoard struct { + wire *compassv1.AgentBoard + refs []string + decodeErr error +} + +func boardAuthoredToWire(account store.Account, row store.BoardRow, matched []string) authoredBoard { + out := authoredBoardIdentity(account, row, matched) + allRefs := make([]string, 0, len(row.Tasks)*2) + var decodeErr error + for _, task := range row.Tasks { + wireTask := &compassv1.BoardTask{ + Key: task.Key, Title: task.Title, PrimaryRef: task.PrimaryRef, Refs: task.Refs, + State: compassv1.BoardTaskState(task.State), Note: task.Note, WrittenAtUnixMs: task.WrittenAtUnixMs, + } + out.Tasks = append(out.Tasks, wireTask) + waiting, err := unmarshalBoardWaiting(task.Waiting) + if err != nil { + decodeErr = errors.Join(decodeErr, fmt.Errorf("decode stored waiting entries for %q: %w", task.Key, err)) + } else { + wireTask.Waiting = waiting + } + var subagents []*compassv1.BoardSubagent + if err := json.Unmarshal(task.Subagents, &subagents); err != nil { + decodeErr = errors.Join(decodeErr, fmt.Errorf("decode stored subagents for %q: %w", task.Key, err)) + } else { + wireTask.Subagents = subagents + } + allRefs = append(allRefs, task.Refs...) + if task.PrimaryRef != "" { + allRefs = append(allRefs, task.PrimaryRef) + } + } + return authoredBoard{wire: out, refs: allRefs, decodeErr: decodeErr} +} + +func (c *Comms) completeBoardWire(ctx context.Context, account store.Account, row store.BoardRow, out *compassv1.AgentBoard, allRefs []string) (*compassv1.AgentBoard, error) { + presence := compassv1.AgentPresence_AGENT_PRESENCE_OFFLINE + if c.presence != nil { + if live := c.presence.PresenceFor([]store.AccountID{account.ID}); live[account.ID] != compassv1.AgentPresence_AGENT_PRESENCE_UNSPECIFIED { + presence = live[account.ID] + } + } + out.Stale = presence == compassv1.AgentPresence_AGENT_PRESENCE_WORKING && row.WrittenAtUnixMs < time.Now().Add(-boardStaleAfter).UnixMilli() + derived, untracked, err := board.DeriveRefs(ctx, c.store, c.boardForges, account.ID, allRefs) + if err != nil { + return out, err + } + out.Refs, out.UntrackedRefs = derived, untracked + return out, nil +} + +func haltToWire(halt store.BoardHalt) *compassv1.BoardHalt { + if halt.Kind == 0 && halt.Ref == "" && halt.Reason == "" { + return nil + } + return &compassv1.BoardHalt{Kind: compassv1.BoardHaltKind(halt.Kind), Ref: halt.Ref, Reason: halt.Reason} +} + +func haltFromWire(halt *compassv1.BoardHalt) (store.BoardHalt, error) { + kind := halt.GetKind() + if kind <= compassv1.BoardHaltKind_BOARD_HALT_KIND_UNSPECIFIED || kind > compassv1.BoardHaltKind_BOARD_HALT_KIND_AWAITING_USER { + return store.BoardHalt{}, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid board halt kind %d", kind)) + } + if err := validateBoardWireString("board halt reason", halt.GetReason(), store.MaxBoardHaltReasonChars); err != nil { + return store.BoardHalt{}, err + } + ref := halt.GetRef() + if strings.ContainsRune(ref, '\x00') { + return store.BoardHalt{}, connect.NewError(connect.CodeInvalidArgument, errors.New("board halt ref contains a NUL character")) + } + if ref != "" { + canonical, err := canonicalRef(ref) + if err != nil { + return store.BoardHalt{}, err + } + ref = canonical + } + return store.BoardHalt{Kind: int16(kind), Ref: ref, Reason: halt.GetReason()}, nil +} diff --git a/go/internal/comms/board_limits_test.go b/go/internal/comms/board_limits_test.go new file mode 100644 index 000000000..1a5af893a --- /dev/null +++ b/go/internal/comms/board_limits_test.go @@ -0,0 +1,177 @@ +package comms + +import ( + "strings" + "testing" + + "connectrpc.com/connect" + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" +) + +func TestBoardWireLimitsRejectOversizedCollections(t *testing.T) { + waitingOn := compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR + tooManyRefs := make([]string, 11) + tooManyBlocks := make([]string, 11) + tooManyWaiting := make([]*compassv1.BoardWaiting, 6) + for i := range tooManyRefs { + tooManyRefs[i] = "not-a-ref" + tooManyBlocks[i] = "not-a-ref" + } + for i := range tooManyWaiting { + tooManyWaiting[i] = &compassv1.BoardWaiting{On: waitingOn} + } + tests := []struct { + name string + call func() error + }{ + { + name: "upsert tasks", + call: func() error { + _, err := boardPatchFromWire(&compassv1.UpdateAgentBoardRequest{UpsertTasks: make([]*compassv1.BoardTaskPatch, 31)}) + return err + }, + }, + { + name: "remove task keys", + call: func() error { + _, err := boardPatchFromWire(&compassv1.UpdateAgentBoardRequest{RemoveTaskKeys: make([]string, 31)}) + return err + }, + }, + { + name: "task refs", + call: func() error { + _, err := boardTaskPatchFromWire(&compassv1.BoardTaskPatch{Refs: &compassv1.BoardRefsOp{Values: tooManyRefs}}) + return err + }, + }, + { + name: "waiting entries", + call: func() error { + _, err := marshalBoardWaiting(tooManyWaiting) + return err + }, + }, + { + name: "waiting blocks", + call: func() error { + _, err := marshalBoardWaiting([]*compassv1.BoardWaiting{{On: waitingOn, Blocks: tooManyBlocks}}) + return err + }, + }, + { + name: "subagents", + call: func() error { + _, err := boardTaskPatchFromWire(&compassv1.BoardTaskPatch{UpsertSubagents: make([]*compassv1.BoardSubagent, 21)}) + return err + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.call() + if connect.CodeOf(err) != connect.CodeInvalidArgument || !strings.Contains(err.Error(), "maximum") { + t.Fatalf("oversized %s error = %v, want invalid_argument naming the maximum", tc.name, err) + } + }) + } +} + +func TestBoardWireRejectsNULAndOverlongSubagentStatus(t *testing.T) { + validOn := compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR + tests := []struct { + name string + call func() error + }{ + { + name: "task title NUL", + call: func() error { + _, err := boardTaskPatchFromWire(&compassv1.BoardTaskPatch{Title: new("bad\x00text")}) + return err + }, + }, + { + name: "halt reason NUL", + call: func() error { + _, err := haltFromWire(&compassv1.BoardHalt{Reason: "bad\x00text"}) + return err + }, + }, + { + name: "waiting what NUL", + call: func() error { + _, err := marshalBoardWaiting([]*compassv1.BoardWaiting{{On: validOn, What: "bad\x00text"}}) + return err + }, + }, + { + name: "primary ref NUL", + call: func() error { + _, err := boardTaskPatchFromWire(&compassv1.BoardTaskPatch{PrimaryRef: new("bad\x00ref")}) + return err + }, + }, + { + name: "refs NUL", + call: func() error { + _, err := boardTaskPatchFromWire(&compassv1.BoardTaskPatch{Refs: &compassv1.BoardRefsOp{Values: []string{"bad\x00ref"}}}) + return err + }, + }, + { + name: "halt ref NUL", + call: func() error { + _, err := haltFromWire(&compassv1.BoardHalt{Kind: compassv1.BoardHaltKind_BOARD_HALT_KIND_BLOCKED, Ref: "bad\x00ref"}) + return err + }, + }, + { + name: "subagent id NUL", + call: func() error { + _, err := boardTaskPatchFromWire(&compassv1.BoardTaskPatch{UpsertSubagents: []*compassv1.BoardSubagent{{Id: "bad\x00id"}}}) + return err + }, + }, + { + name: "task key NUL", + call: func() error { + _, err := boardTaskPatchFromWire(&compassv1.BoardTaskPatch{Key: "bad\x00key"}) + return err + }, + }, + { + name: "subagent status NUL", + call: func() error { + _, err := boardTaskPatchFromWire(&compassv1.BoardTaskPatch{UpsertSubagents: []*compassv1.BoardSubagent{{Status: "bad\x00status"}}}) + return err + }, + }, + { + name: "subagent status length", + call: func() error { + _, err := boardTaskPatchFromWire(&compassv1.BoardTaskPatch{UpsertSubagents: []*compassv1.BoardSubagent{{Status: strings.Repeat("s", 65)}}}) + return err + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := tc.call(); connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("error = %v, want invalid_argument", err) + } + }) + } +} + +func TestMarshalBoardWaitingRejectsNilEntry(t *testing.T) { + _, err := marshalBoardWaiting([]*compassv1.BoardWaiting{nil}) + if connect.CodeOf(err) != connect.CodeInvalidArgument || !strings.Contains(err.Error(), "board waiting entry is required") { + t.Fatalf("marshalBoardWaiting(nil): %v, want invalid_argument for required entry", err) + } + _, err = boardTaskPatchFromWire(&compassv1.BoardTaskPatch{ + Waiting: &compassv1.BoardWaitingOp{Values: []*compassv1.BoardWaiting{nil}}, + }) + if connect.CodeOf(err) != connect.CodeInvalidArgument || strings.Contains(err.Error(), "encode board waiting entries") { + t.Fatalf("boardTaskPatchFromWire(nil waiting entry): %v, want original connect error", err) + } +} diff --git a/go/internal/comms/board_pgtest_test.go b/go/internal/comms/board_pgtest_test.go new file mode 100644 index 000000000..84b3ae8fc --- /dev/null +++ b/go/internal/comms/board_pgtest_test.go @@ -0,0 +1,706 @@ +//go:build pgtest + +package comms + +import ( + "context" + "fmt" + "math" + "slices" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/jackc/pgx/v5/pgxpool" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/board" + "github.com/RigelBuild/compass/go/internal/store" +) + +func TestUpdateAgentBoardPresenceMergeAndInvalidState(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + owner := mustUser(t, st, "board-owner") + agent := mustAgent(t, st, owner.ID, "board-agent") + state := compassv1.BoardTaskState_BOARD_TASK_STATE_ACTIVE + + first, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{ + Key: "task", Title: new("Board task"), Note: new("first note"), State: &state, + Refs: &compassv1.BoardRefsOp{Values: []string{"RIG-12"}}, + }}, + FocusTaskKey: new("task"), TurnSequence: 1, + }) + if err != nil { + t.Fatalf("initial UpdateBoardAsAccount: %v", err) + } + if len(first.GetBoard().GetTasks()) != 1 || first.GetBoard().GetTasks()[0].GetRefs()[0] != "RIG-12" { + t.Fatalf("initial board = %v", first.GetBoard()) + } + + unspecified := compassv1.BoardTaskState_BOARD_TASK_STATE_UNSPECIFIED + _, err = svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", State: &unspecified}}, + }) + connectCodeIs(t, err, connect.CodeInvalidArgument, "unspecified state") + rows, err := st.AgentBoards(ctx, []store.AccountID{agent.ID}) + if err != nil { + t.Fatalf("AgentBoards after invalid state: %v", err) + } + if got := rows[agent.ID].Tasks[0].State; got != int16(compassv1.BoardTaskState_BOARD_TASK_STATE_ACTIVE) { + t.Fatalf("invalid state changed stored state to %d", got) + } + unknown := compassv1.BoardTaskState(math.MaxInt32) + _, err = svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", State: &unknown}}, + }) + connectCodeIs(t, err, connect.CodeInvalidArgument, "unknown state") + rows, err = st.AgentBoards(ctx, []store.AccountID{agent.ID}) + if err != nil { + t.Fatalf("AgentBoards after unknown state: %v", err) + } + if got := rows[agent.ID].Tasks[0].State; got != int16(compassv1.BoardTaskState_BOARD_TASK_STATE_ACTIVE) { + t.Fatalf("unknown state changed stored state to %d", got) + } + clear := "" + emptyRefs := &compassv1.BoardRefsOp{} + merged, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Note: &clear, Refs: emptyRefs}}, TurnSequence: 2, + }) + if err != nil { + t.Fatalf("presence-aware UpdateBoardAsAccount: %v", err) + } + task := merged.GetBoard().GetTasks()[0] + if task.GetNote() != "" || len(task.GetRefs()) != 0 || task.GetTitle() != "Board task" { + t.Fatalf("merged task = %v, want empty note/refs and kept title", task) + } +} + +func TestUpdateBoardWaitingRefsCanonicalAndValidated(t *testing.T) { + svc, st := newHandler(t) + ctx := t.Context() + owner := mustUser(t, st, "board-waiting-ref-owner") + agent := mustAgent(t, st, owner.ID, "board-waiting-ref-agent") + on := compassv1.BoardWaitingOn_BOARD_WAITING_ON_EXTERNAL + _, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{ + Key: "task", Title: new("Waiting task"), + Waiting: &compassv1.BoardWaitingOp{Values: []*compassv1.BoardWaiting{{ + On: on, Ref: "Owner/Repo#12", Blocks: []string{"Other/Repo#13"}, + }}}, + }}, + }) + if err != nil { + t.Fatalf("UpdateBoardAsAccount(canonical waiting refs): %v", err) + } + rows, err := st.AgentBoards(ctx, []store.AccountID{agent.ID}) + if err != nil { + t.Fatalf("AgentBoards: %v", err) + } + waiting, err := unmarshalBoardWaiting(rows[agent.ID].Tasks[0].Waiting) + if err != nil { + t.Fatalf("unmarshalBoardWaiting: %v", err) + } + if waiting[0].GetRef() != "owner/repo#12" || len(waiting[0].GetBlocks()) != 1 || waiting[0].GetBlocks()[0] != "other/repo#13" { + t.Fatalf("stored waiting refs = %v, want canonical refs", waiting[0]) + } + + for _, tc := range []struct { + name string + item *compassv1.BoardWaiting + }{ + {name: "ref", item: &compassv1.BoardWaiting{On: on, Ref: "invalid ref"}}, + {name: "blocks", item: &compassv1.BoardWaiting{On: on, Blocks: []string{"invalid ref"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Waiting: &compassv1.BoardWaitingOp{Values: []*compassv1.BoardWaiting{tc.item}}}}, + }) + connectCodeIs(t, err, connect.CodeInvalidArgument, "invalid waiting "+tc.name) + }) + } +} + +func TestUpdateBoardRejectsUnspecifiedHalt(t *testing.T) { + svc, st := newHandler(t) + owner := mustUser(t, st, "board-unspecified-halt-owner") + agent := mustAgent(t, st, owner.ID, "board-unspecified-halt-agent") + _, err := svc.UpdateBoardAsAccount(t.Context(), agent.ID, &compassv1.UpdateAgentBoardRequest{ + Halt: &compassv1.BoardHalt{Kind: compassv1.BoardHaltKind_BOARD_HALT_KIND_UNSPECIFIED}, + }) + connectCodeIs(t, err, connect.CodeInvalidArgument, "present unspecified halt") + rows, err := st.AgentBoards(t.Context(), []store.AccountID{agent.ID}) + if err != nil { + t.Fatalf("AgentBoards after rejected halt: %v", err) + } + if _, ok := rows[agent.ID]; ok { + t.Fatalf("unspecified halt created a board: %+v", rows[agent.ID]) + } +} + +func TestUpdateBoardRejectsConflictingHaltOperations(t *testing.T) { + svc, st := newHandler(t) + owner := mustUser(t, st, "board-halt-owner") + agent := mustAgent(t, st, owner.ID, "board-halt-agent") + _, err := svc.UpdateBoardAsAccount(t.Context(), agent.ID, &compassv1.UpdateAgentBoardRequest{ + Halt: &compassv1.BoardHalt{Kind: compassv1.BoardHaltKind_BOARD_HALT_KIND_BLOCKED}, ClearHalt: true, + }) + connectCodeIs(t, err, connect.CodeInvalidArgument, "conflicting halt and clear_halt") + rows, err := st.AgentBoards(t.Context(), []store.AccountID{agent.ID}) + if err != nil { + t.Fatalf("AgentBoards after rejected patch: %v", err) + } + if _, ok := rows[agent.ID]; ok { + t.Fatalf("conflicting halt patch created board: %+v", rows[agent.ID]) + } +} + +func TestListAgentBoardsRejectsInvalidFilters(t *testing.T) { + svc, st := newHandler(t) + owner := mustUser(t, st, "board-invalid-filter-owner") + filters := []struct { + name string + req *compassv1.ListAgentBoardsRequest + }{ + {name: "waiting enum", req: &compassv1.ListAgentBoardsRequest{WaitingOn: compassv1.BoardWaitingOn(99)}}, + {name: "state enum", req: &compassv1.ListAgentBoardsRequest{State: compassv1.BoardTaskState(99)}}, + {name: "halt enum", req: &compassv1.ListAgentBoardsRequest{Halt: compassv1.BoardHaltKind(99)}}, + {name: "ref grammar", req: &compassv1.ListAgentBoardsRequest{Ref: "not a ref"}}, + {name: "query NUL", req: &compassv1.ListAgentBoardsRequest{Query: "a\x00b"}}, + {name: "handle NUL", req: &compassv1.ListAgentBoardsRequest{AgentHandles: []string{"a\x00b"}}}, + } + for _, tc := range filters { + t.Run(tc.name, func(t *testing.T) { + _, err := svc.ListBoardsAsAccount(t.Context(), owner.ID, tc.req) + connectCodeIs(t, err, connect.CodeInvalidArgument, tc.name) + }) + } +} + +func TestUpdateBoardRejectsNonAgentBeforeWrite(t *testing.T) { + svc, st := newHandler(t) + owner := mustUser(t, st, "board-non-agent") + _, err := svc.UpdateBoardAsAccount(context.Background(), owner.ID, &compassv1.UpdateAgentBoardRequest{}) + connectCodeIs(t, err, connect.CodePermissionDenied, "non-agent board writer") + rows, err := st.AgentBoards(context.Background(), []store.AccountID{owner.ID}) + if err != nil { + t.Fatalf("AgentBoards after rejected write: %v", err) + } + if len(rows) != 0 { + t.Fatalf("non-agent write created board: %+v", rows[owner.ID]) + } +} + +func TestListAgentBoardsVisibilityAndFilters(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + owner := mustUser(t, st, "board-visible-owner") + stranger := mustUser(t, st, "board-visible-stranger") + agent := mustAgent(t, st, owner.ID, "board-visible-agent") + peer := mustChildAgent(t, st, owner.ID, "board-visible-peer", agent.ID) + state := compassv1.BoardTaskState_BOARD_TASK_STATE_ACTIVE + blocked := compassv1.BoardTaskState_BOARD_TASK_STATE_BLOCKED + waitingOn := compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR + if _, err := svc.UpdateBoardAsAccount(ctx, peer.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{ + { + Key: "peer-task", Title: new("Needs operator review"), Note: new("deploy release"), State: &blocked, + PrimaryRef: new("RIG-42"), Waiting: &compassv1.BoardWaitingOp{Values: []*compassv1.BoardWaiting{{On: waitingOn}}}, + }, + {Key: "other-task", Title: new("Unrelated work"), Note: new("do not match"), State: &state}, + }, + Halt: &compassv1.BoardHalt{Kind: compassv1.BoardHaltKind_BOARD_HALT_KIND_BLOCKED}, + }); err != nil { + t.Fatalf("UpdateBoardAsAccount(peer): %v", err) + } + if _, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Title: new("Visible task"), State: &state}}, + }); err != nil { + t.Fatalf("UpdateBoardAsAccount: %v", err) + } + visible, err := svc.ListBoardsAsAccount(ctx, peer.ID, &compassv1.ListAgentBoardsRequest{}) + if err != nil { + t.Fatalf("peer ListBoardsAsAccount: %v", err) + } + if len(visible.GetBoards()) != 2 { + t.Fatalf("peer boards = %v, want both same-owner roster-visible agents", visible.GetBoards()) + } + if !slices.ContainsFunc(visible.GetBoards(), func(got *compassv1.AgentBoard) bool { + return got.GetAgentAccountId() == string(agent.ID) + }) { + t.Fatalf("peer boards omitted roster-visible agent %q: %v", agent.ID, visible.GetBoards()) + } + qualifiedVisible, err := svc.ListBoardsAsAccount(ctx, peer.ID, &compassv1.ListAgentBoardsRequest{AgentHandles: []string{owner.Handle + "/" + agent.Handle}}) + if err != nil || len(qualifiedVisible.GetBoards()) != 1 || qualifiedVisible.GetBoards()[0].GetAgentAccountId() != string(agent.ID) { + t.Fatalf("qualified handle filter = %v, %v; want visible target agent", qualifiedVisible, err) + } + filtered, err := svc.ListBoardsAsAccount(ctx, peer.ID, &compassv1.ListAgentBoardsRequest{AgentHandles: []string{"missing-agent"}}) + if err != nil { + t.Fatalf("filtered ListBoardsAsAccount: %v", err) + } + if len(filtered.GetBoards()) != 0 { + t.Fatalf("handle filter returned boards: %v", filtered.GetBoards()) + } + filters := []struct { + name string + req *compassv1.ListAgentBoardsRequest + matchedKeys []string + }{ + {name: "query", req: &compassv1.ListAgentBoardsRequest{Query: "deploy"}, matchedKeys: []string{"peer-task"}}, + {name: "ref", req: &compassv1.ListAgentBoardsRequest{Ref: "RIG-42"}, matchedKeys: []string{"peer-task"}}, + {name: "waiting", req: &compassv1.ListAgentBoardsRequest{WaitingOn: waitingOn}, matchedKeys: []string{"peer-task"}}, + {name: "state", req: &compassv1.ListAgentBoardsRequest{State: blocked}, matchedKeys: []string{"peer-task"}}, + {name: "halt", req: &compassv1.ListAgentBoardsRequest{Halt: compassv1.BoardHaltKind_BOARD_HALT_KIND_BLOCKED}, matchedKeys: []string{"peer-task", "other-task"}}, + } + for _, tc := range filters { + t.Run(tc.name, func(t *testing.T) { + got, err := svc.ListBoardsAsAccount(ctx, peer.ID, tc.req) + if err != nil { + t.Fatalf("ListBoardsAsAccount(%s): %v", tc.name, err) + } + if len(got.GetBoards()) != 1 || got.GetBoards()[0].GetAgentAccountId() != string(peer.ID) { + t.Fatalf("%s filter returned boards %v, want peer board", tc.name, got.GetBoards()) + } + board := got.GetBoards()[0] + if !slices.Equal(board.GetMatchedTaskKeys(), tc.matchedKeys) { + t.Errorf("%s matched task keys = %v, want %v", tc.name, board.GetMatchedTaskKeys(), tc.matchedKeys) + } + if len(board.GetTasks()) != 2 || board.GetTasks()[1].GetKey() != "other-task" { + t.Errorf("%s board tasks = %v, want whole two-task board", tc.name, board.GetTasks()) + } + }) + } + invisible, err := svc.ListBoardsAsAccount(ctx, stranger.ID, &compassv1.ListAgentBoardsRequest{}) + if err != nil { + t.Fatalf("stranger ListBoardsAsAccount: %v", err) + } + if len(invisible.GetBoards()) != 0 { + t.Fatalf("stranger saw invisible agent board: %v", invisible.GetBoards()) + } +} + +func TestListAgentBoardsIgnoresUnknownHandles(t *testing.T) { + svc, st := newHandler(t) + ctx := t.Context() + owner := mustUser(t, st, "board-unknown-handle-owner") + agent := mustAgent(t, st, owner.ID, "board-unknown-handle-agent") + if _, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Title: new("Task")}}, + }); err != nil { + t.Fatalf("UpdateBoardAsAccount: %v", err) + } + got, err := svc.ListBoardsAsAccount(ctx, owner.ID, &compassv1.ListAgentBoardsRequest{ + AgentHandles: []string{"not-an-agent", agent.Handle}, + }) + if err != nil { + t.Fatalf("ListBoardsAsAccount with unknown handle: %v", err) + } + if len(got.GetBoards()) != 1 || got.GetBoards()[0].GetAgentAccountId() != string(agent.ID) { + t.Fatalf("ListBoardsAsAccount returned %v, want only the visible agent board", got.GetBoards()) + } +} + +func TestListAgentBoardsSurfacesResolverOperationalErrors(t *testing.T) { + svc, st := newHandler(t) + owner := mustUser(t, st, "board-resolver-error-owner") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := svc.ListBoardsAsAccount(ctx, owner.ID, &compassv1.ListAgentBoardsRequest{AgentHandles: []string{"possibly-an-agent"}}) + connectCodeIs(t, err, connect.CodeInternal, "canceled handle resolution") +} + +func TestListAgentBoardsDeduplicatesNonadjacentHandles(t *testing.T) { + svc, st := newHandler(t) + ctx := t.Context() + owner := mustUser(t, st, "board-handle-dedupe-owner") + first := mustAgent(t, st, owner.ID, "board-handle-dedupe-first") + second := mustAgent(t, st, owner.ID, "board-handle-dedupe-second") + for _, agent := range []store.Account{first, second} { + if _, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Title: new(agent.Handle)}}, + }); err != nil { + t.Fatalf("UpdateBoardAsAccount(%s): %v", agent.Handle, err) + } + } + got, err := svc.ListBoardsAsAccount(ctx, owner.ID, &compassv1.ListAgentBoardsRequest{ + AgentHandles: []string{first.Handle, second.Handle, first.Handle}, + }) + if err != nil { + t.Fatalf("ListBoardsAsAccount: %v", err) + } + if len(got.GetBoards()) != 2 { + t.Fatalf("boards = %v, want both matching boards once", got.GetBoards()) + } +} + +func TestListAgentBoardsRejectsMoreThan100UniqueHandles(t *testing.T) { + svc, st := newHandler(t) + owner := mustUser(t, st, "board-handle-limit-owner") + handles := make([]string, 101) + for i := range handles { + handles[i] = fmt.Sprintf("missing-agent-%d", i) + } + _, err := svc.ListBoardsAsAccount(t.Context(), owner.ID, &compassv1.ListAgentBoardsRequest{AgentHandles: handles}) + connectCodeIs(t, err, connect.CodeInvalidArgument, "maximum") +} +func TestListAgentBoardsRejectsMoreThan100RawHandles(t *testing.T) { + svc, st := newHandler(t) + owner := mustUser(t, st, "board-raw-handle-limit-owner") + handles := make([]string, 101) + for i := range handles { + handles[i] = "same-missing-agent" + } + _, err := svc.ListBoardsAsAccount(t.Context(), owner.ID, &compassv1.ListAgentBoardsRequest{AgentHandles: handles}) + connectCodeIs(t, err, connect.CodeInvalidArgument, "maximum") +} + +func TestListAgentBoardsIncludesEmptyHaltedBoards(t *testing.T) { + svc, st := newHandler(t) + ctx := t.Context() + owner := mustUser(t, st, "board-empty-halted-owner") + agent := mustAgent(t, st, owner.ID, "board-empty-halted-agent") + if _, err := st.ApplyBoardPatch(ctx, agent.ID, store.BoardPatch{ + Halt: &store.BoardHalt{Kind: int16(compassv1.BoardHaltKind_BOARD_HALT_KIND_BLOCKED)}, NowUnixMs: 1, + }); err != nil { + t.Fatalf("ApplyBoardPatch(empty halted): %v", err) + } + resp, err := svc.ListBoardsAsAccount(ctx, owner.ID, &compassv1.ListAgentBoardsRequest{ + Halt: compassv1.BoardHaltKind_BOARD_HALT_KIND_BLOCKED, + }) + if err != nil { + t.Fatalf("ListBoardsAsAccount: %v", err) + } + if len(resp.GetBoards()) != 1 || len(resp.GetBoards()[0].GetTasks()) != 0 || resp.GetBoards()[0].GetHalt().GetKind() != compassv1.BoardHaltKind_BOARD_HALT_KIND_BLOCKED { + t.Fatalf("halted empty board result = %v, want one zero-task halted board", resp.GetBoards()) + } +} + +func TestListAgentBoardsRejectsInvalidHaltFilter(t *testing.T) { + svc, st := newHandler(t) + owner := mustUser(t, st, "board-invalid-halt-filter-owner") + _, err := svc.ListBoardsAsAccount(t.Context(), owner.ID, &compassv1.ListAgentBoardsRequest{ + Halt: compassv1.BoardHaltKind(math.MaxInt32), + }) + connectCodeIs(t, err, connect.CodeInvalidArgument, "invalid board halt filter") +} + +func TestAutomaticEmptyBoardPatchDoesNotPublish(t *testing.T) { + h := newStreamHarness(t) + ctx := t.Context() + owner := mustUser(t, h.store, "board-empty-auto-owner") + agent := mustAgent(t, h.store, owner.ID, "board-empty-auto-agent") + if _, err := h.svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Title: new("Existing task")}}, + }); err != nil { + t.Fatalf("seed board: %v", err) + } + before, err := h.bus.Subscribe(0, 0) + if err != nil { + t.Fatalf("Subscribe(before): %v", err) + } + defer before.Cancel() + if _, err := h.svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{Automatic: true}); err != nil { + t.Fatalf("empty automatic update: %v", err) + } + after, err := h.bus.Subscribe(0, 0) + if err != nil { + t.Fatalf("Subscribe(after): %v", err) + } + defer after.Cancel() + if after.HeadSeq != before.HeadSeq { + t.Fatalf("event head after empty automatic patch = %d, want unchanged %d", after.HeadSeq, before.HeadSeq) + } +} + +func TestBoardChangedUsesBoardVisibilityAndHasNoAsks(t *testing.T) { + h := newStreamHarness(t) + ctx := context.Background() + owner := mustUser(t, h.store, "board-event-owner") + stranger := mustUser(t, h.store, "board-event-stranger") + peer := mustUser(t, h.store, "board-event-peer") + agent := mustAgent(t, h.store, owner.ID, "board-event-agent") + if _, _, err := h.store.UpdateChannelMembers(ctx, owner.ID, agent.Agent.HomeChannelID, []store.MemberUpdate{{AccountID: peer.ID}}, store.MemberUpdatesOptions{}); err != nil { + t.Fatalf("UpdateChannelMembers(peer visibility): %v", err) + } + state := compassv1.BoardTaskState_BOARD_TASK_STATE_ACTIVE + stream := firstEventAfterBoundary(t, h, owner.ID, &compassv1.SubscribeCommsRequest{SinceSeq: 0}) + peerEvents := firstEventAfterBoundary(t, h, peer.ID, &compassv1.SubscribeCommsRequest{SinceSeq: 0}) + if _, err := h.svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Title: new("Task"), PrimaryRef: new("RIG-42"), Note: new("Keep this authored note"), State: &state}}, + }); err != nil { + t.Fatalf("UpdateBoardAsAccount: %v", err) + } + got := awaitFirst(t, stream) + if peerEvent := awaitFirst(t, peerEvents).GetAgentBoardChanged(); peerEvent == nil || peerEvent.GetAgentAccountId() != string(agent.ID) { + t.Fatalf("peer-visible event = %v, want board event for agent", peerEvent) + } + event := got.GetAgentBoardChanged() + if event == nil || event.GetBoard() == nil || event.GetAgentAccountId() != string(agent.ID) || event.GetBoard().GetHandle() != agent.Handle { + t.Fatalf("board event = %v, want a board change for agent %q", got, agent.Handle) + } + boardEvent := event.GetBoard() + if len(boardEvent.GetRefs()) != 0 || len(boardEvent.GetUntrackedRefs()) != 0 || boardEvent.GetStale() || len(boardEvent.GetAsks()) != 0 { + t.Fatalf("board event contains derived fields: %v", boardEvent) + } + if len(boardEvent.GetTasks()) != 1 || boardEvent.GetTasks()[0].GetPrimaryRef() != "RIG-42" || boardEvent.GetTasks()[0].GetNote() != "Keep this authored note" { + t.Fatalf("board event omitted authored task fields: %v", boardEvent.GetTasks()) + } + + other, err := h.svc.ListBoardsAsAccount(ctx, stranger.ID, &compassv1.ListAgentBoardsRequest{}) + if err != nil { + t.Fatalf("stranger board list: %v", err) + } + if len(other.GetBoards()) != 0 { + t.Fatalf("stranger saw board: %v", other.GetBoards()) + } + strangerEvents := firstEventAfterBoundary(t, h, stranger.ID, &compassv1.SubscribeCommsRequest{SinceSeq: 0}) + if _, err := h.svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Note: new("second update")}}, + }); err != nil { + t.Fatalf("second UpdateBoardAsAccount: %v", err) + } + canary, err := h.store.CreateChannel(ctx, stranger.ID, store.NewChannel{Name: "board-event-canary", Kind: store.ChannelKindChannel}) + if err != nil { + t.Fatalf("CreateChannel(canary): %v", err) + } + h.bus.Publish(&compassv1.SubscribeCommsResponse{ + Payload: &compassv1.SubscribeCommsResponse_ChannelChanged{ + ChannelChanged: &compassv1.ChannelChanged{Channel: &compassv1.Channel{Id: string(canary.ID)}}, + }, + }) + strangerFirst := awaitFirst(t, strangerEvents) + peerVisible, err := h.svc.ListBoardsAsAccount(ctx, peer.ID, &compassv1.ListAgentBoardsRequest{}) + if err != nil || len(peerVisible.GetBoards()) != 1 { + t.Fatalf("peer board list = %v, %v; want roster-visible board", peerVisible, err) + } + if strangerFirst.GetAgentBoardChanged() != nil { + t.Fatalf("stranger received board event: %v", strangerFirst) + } + if strangerFirst.GetChannelChanged() == nil || strangerFirst.GetChannelChanged().GetChannel().GetId() != string(canary.ID) { + t.Fatalf("stranger first event = %v, want visible canary channel", strangerFirst) + } +} +func TestBoardChangedPublishedWhenDerivedResponseFails(t *testing.T) { + h := newStreamHarness(t) + ctx := t.Context() + owner := mustUser(t, h.store, "board-event-failure-owner") + agent := mustAgent(t, h.store, owner.ID, "board-event-failure-agent") + if _, err := h.store.UpsertPullRequest(ctx, store.PullRequestRow{ + Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 12}, + State: "open", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), PR: []byte(`{"number":"invalid"}`), + }, nil); err != nil { + t.Fatalf("UpsertPullRequest(invalid protobuf JSON): %v", err) + } + h.svc.SetBoardForges(board.Forges{GitHubHost: "github.com"}) + sub, err := h.bus.Subscribe(0, 0) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer sub.Cancel() + waitingOn := compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR + updated, err := h.svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{ + Key: "task", Title: new("Authored task"), PrimaryRef: new("owner/repo#12"), + Waiting: &compassv1.BoardWaitingOp{Values: []*compassv1.BoardWaiting{{On: waitingOn, What: "waiting"}}}, + UpsertSubagents: []*compassv1.BoardSubagent{{Id: "child", Agent: "implement", Description: "checking", Status: "running"}}, + }}, + }) + if err != nil { + t.Fatalf("UpdateBoardAsAccount: %v", err) + } + assertAuthoredOnlyBoard(t, updated.GetBoard()) + task := updated.GetBoard().GetTasks()[0] + if len(task.GetWaiting()) != 1 || task.GetWaiting()[0].GetWhat() != "waiting" { + t.Fatalf("decoded waiting entries were lost after ref derivation failed: %v", task.GetWaiting()) + } + if len(task.GetSubagents()) != 1 || task.GetSubagents()[0].GetId() != "child" { + t.Fatalf("decoded subagents were lost after ref derivation failed: %v", task.GetSubagents()) + } + listed, err := h.svc.ListBoardsAsAccount(ctx, owner.ID, &compassv1.ListAgentBoardsRequest{}) + if err != nil { + t.Fatalf("ListBoardsAsAccount: %v", err) + } + if len(listed.GetBoards()) != 1 { + t.Fatalf("listed boards = %v, want the authored board", listed.GetBoards()) + } + assertAuthoredOnlyBoard(t, listed.GetBoards()[0]) + event := <-sub.Live + if changed := event.Payload.GetAgentBoardChanged(); changed == nil || changed.GetBoard() == nil { + t.Fatalf("published event = %v, want authored board event", event.Payload) + } else { + assertAuthoredOnlyBoard(t, changed.GetBoard()) + } +} + +func TestUpdateBoardMalformedStoredSubagentsPreservesLaterTasks(t *testing.T) { + ctx := context.Background() + st, dsn := newTestStoreDSN(t) + owner := mustUser(t, st, "board-malformed-owner") + agent := mustAgent(t, st, owner.ID, "board-malformed-agent") + bus := newBus(t) + svc := NewComms(st, bus, nil, owner.ID) + if _, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{ + {Key: "first", Title: new("First task")}, + {Key: "task", Title: new("Stored title"), Note: new("Stored note")}, + {Key: "third", Title: new("Third task")}, + }, + }); err != nil { + t.Fatalf("seed board: %v", err) + } + sub, err := bus.Subscribe(0, 0) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer sub.Cancel() + oldWrittenAt := corruptStoredBoardTask(t, ctx, st, dsn, agent.ID, "task", "subagents") + svc.SetPresenceSource(fakePresenceSource{presence: map[store.AccountID]compassv1.AgentPresence{ + agent.ID: compassv1.AgentPresence_AGENT_PRESENCE_WORKING, + }}) + response, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Title: new("Updated title")}}, + FocusTaskKey: new("task"), + Automatic: true, + }) + if err != nil { + t.Fatalf("UpdateBoardAsAccount after committed patch: %v", err) + } + assertMalformedBoardUpdateFallback(t, response, oldWrittenAt) + event := <-sub.Live + assertMalformedBoardEventFallback(t, event.Payload) + assertMalformedBoardReadFallbacks(t, svc, ctx, owner.ID, agent, "Updated title") +} + +// The store rejects writes over a board whose stored waiting is invalid, so +// this decode branch is only reachable from the read paths. +func TestListAndRosterMalformedStoredWaitingPreservesLaterTasks(t *testing.T) { + for _, shape := range []string{"waiting", "waiting-object"} { + t.Run(shape, func(t *testing.T) { testMalformedStoredWaiting(t, shape) }) + } +} + +func testMalformedStoredWaiting(t *testing.T, shape string) { + t.Helper() + ctx := context.Background() + st, dsn := newTestStoreDSN(t) + owner := mustUser(t, st, "board-malformed-waiting-owner") + agent := mustAgent(t, st, owner.ID, "board-malformed-waiting-agent") + svc := NewComms(st, newBus(t), nil, owner.ID) + if _, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{ + {Key: "first", Title: new("First task")}, + {Key: "task", Title: new("Updated title"), Note: new("Stored note"), Waiting: &compassv1.BoardWaitingOp{ + Values: []*compassv1.BoardWaiting{{On: compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR, What: "review"}}, + }}, + {Key: "third", Title: new("Third task")}, + }, + FocusTaskKey: new("task"), + }); err != nil { + t.Fatalf("seed board: %v", err) + } + corruptStoredBoardTask(t, ctx, st, dsn, agent.ID, "task", shape) + filtered, err := svc.ListBoardsAsAccount(ctx, owner.ID, &compassv1.ListAgentBoardsRequest{WaitingOn: compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR}) + if err != nil { + t.Fatalf("waiting_on filter over malformed stored waiting: %v", err) + } + if len(filtered.GetBoards()) != 0 { + t.Fatalf("waiting_on filter matched malformed waiting: %v", filtered.GetBoards()) + } + svc.SetPresenceSource(fakePresenceSource{presence: map[store.AccountID]compassv1.AgentPresence{ + agent.ID: compassv1.AgentPresence_AGENT_PRESENCE_WORKING, + }}) + assertMalformedBoardReadFallbacks(t, svc, ctx, owner.ID, agent, "Updated title") +} + +func corruptStoredBoardTask(t *testing.T, ctx context.Context, st *store.Store, dsn string, agent store.AccountID, taskKey, column string) int64 { + t.Helper() + oldWrittenAt := time.Now().Add(-31 * time.Minute).UnixMilli() + if _, err := st.ApplyBoardPatch(ctx, agent, store.BoardPatch{NowUnixMs: oldWrittenAt}); err != nil { + t.Fatalf("age board: %v", err) + } + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect to test schema: %v", err) + } + defer pool.Close() + malformed := map[string]string{ + "subagents": `[{"id":"child","started_at_unix_ms":"invalid"}]`, + "waiting": `[{"on":"NOT_A_WAITING_KIND"}]`, + "waiting-object": `{"on":"BOARD_WAITING_ON_OPERATOR"}`, + }[column] + target := strings.TrimSuffix(column, "-object") + tag, err := pool.Exec(ctx, "UPDATE agent_board_tasks SET "+target+" = $4::jsonb, written_at_unix_ms = $3 WHERE agent_account_id = $1 AND task_key = $2", string(agent), taskKey, oldWrittenAt, malformed) + if err != nil || tag.RowsAffected() != 1 { + t.Fatalf("corrupt and age stored %s data: rows=%d err=%v", column, tag.RowsAffected(), err) + } + return oldWrittenAt +} + +func assertMalformedBoardUpdateFallback(t *testing.T, response *compassv1.UpdateAgentBoardResponse, oldWrittenAt int64) { + t.Helper() + got := response.GetBoard() + if got == nil || got.GetFocusTaskKey() != "task" || len(got.GetTasks()) != 3 || got.GetTasks()[1].GetTitle() != "Updated title" || got.GetTasks()[2].GetKey() != "third" { + t.Fatalf("board response after malformed stored data = %v", got) + } + if len(got.GetTasks()[1].GetSubagents()) != 0 || len(got.GetTasks()[1].GetWaiting()) != 0 || got.GetWrittenAtUnixMs() != oldWrittenAt || len(got.GetAsks()) != 0 { + t.Fatalf("board fallback has malformed fields: %v", got) + } + if !got.GetStale() { + t.Fatalf("board fallback lost derived stale state: %v", got) + } +} + +func assertMalformedBoardEventFallback(t *testing.T, event *compassv1.SubscribeCommsResponse) { + t.Helper() + changed := event.GetAgentBoardChanged() + if changed == nil { + t.Fatalf("fallback event after malformed stored data = %v", event.GetPayload()) + } + got := changed.GetBoard() + if got == nil || got.GetFocusTaskKey() != "task" || len(got.GetTasks()) != 3 || got.GetTasks()[1].GetTitle() != "Updated title" || got.GetTasks()[1].GetNote() != "Stored note" || got.GetTasks()[2].GetKey() != "third" { + t.Fatalf("fallback event after malformed stored data = %v", event.GetPayload()) + } + if len(got.GetTasks()[1].GetSubagents()) != 0 || len(got.GetRefs()) != 0 || len(got.GetAsks()) != 0 { + t.Fatalf("fallback event includes malformed or derived fields: %v", got) + } +} + +func assertMalformedBoardReadFallbacks(t *testing.T, svc *Comms, ctx context.Context, owner store.AccountID, agent store.Account, focusTitle string) { + t.Helper() + listed, err := svc.ListBoardsAsAccount(ctx, owner, &compassv1.ListAgentBoardsRequest{AgentHandles: []string{agent.Handle}, Query: "Updated"}) + if err != nil { + t.Fatalf("ListBoardsAsAccount with malformed stored data: %v", err) + } + if len(listed.GetBoards()) != 1 || listed.GetBoards()[0].GetFocusTaskKey() != "task" || len(listed.GetBoards()[0].GetTasks()) != 3 || listed.GetBoards()[0].GetTasks()[1].GetTitle() != "Updated title" || listed.GetBoards()[0].GetTasks()[2].GetKey() != "third" { + t.Fatalf("board list after malformed stored data = %v, want all authored tasks", listed.GetBoards()) + } + if !slices.Equal(listed.GetBoards()[0].GetMatchedTaskKeys(), []string{"task"}) { + t.Fatalf("board list fallback matched task keys = %v, want task", listed.GetBoards()[0].GetMatchedTaskKeys()) + } + if len(listed.GetBoards()[0].GetAsks()) != 0 || !listed.GetBoards()[0].GetStale() { + t.Fatalf("board list fallback = %v, want derived stale state and no asks", listed.GetBoards()[0]) + } + roster, err := svc.RosterAsAccount(ctx, owner, &compassv1.GetRosterRequest{Scope: compassv1.RosterScope_ROSTER_SCOPE_OWNER}) + if err != nil { + t.Fatalf("RosterAsAccount with malformed stored data: %v", err) + } + rosterEntry := rosterByID(roster.GetEntries())[string(agent.ID)] + if rosterEntry == nil || rosterEntry.GetBoard() == nil || rosterEntry.GetBoard().GetTaskCount() != 3 || rosterEntry.GetBoard().GetFocus() != focusTitle || !rosterEntry.GetBoard().GetStale() { + t.Fatalf("roster summary after malformed stored data = %v, want all tasks, focus, and stale fields", rosterEntry) + } + if len(listed.GetBoards()[0].GetTasks()[1].GetWaiting()) != 0 || rosterEntry.GetBoard().GetWaitingOnOperator() != 0 { + t.Fatalf("malformed waiting surfaced: list task %v, roster %v", listed.GetBoards()[0].GetTasks()[1], rosterEntry.GetBoard()) + } +} + +func assertAuthoredOnlyBoard(t *testing.T, got *compassv1.AgentBoard) { + t.Helper() + if got == nil || len(got.GetRefs()) != 0 || len(got.GetUntrackedRefs()) != 0 || got.GetStale() || len(got.GetAsks()) != 0 { + t.Fatalf("board = %v, want authored-only fallback without derived fields", got) + } + if len(got.GetTasks()) != 1 || got.GetTasks()[0].GetTitle() != "Authored task" || got.GetTasks()[0].GetPrimaryRef() != "owner/repo#12" { + t.Fatalf("board tasks = %v, want authored task fields", got.GetTasks()) + } +} diff --git a/go/internal/comms/comms.go b/go/internal/comms/comms.go index ef58fc266..d6f4b4bb8 100644 --- a/go/internal/comms/comms.go +++ b/go/internal/comms/comms.go @@ -31,6 +31,7 @@ import ( "github.com/RigelBuild/compass/go/events" compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" "github.com/RigelBuild/compass/go/gen/compass/v1/compassv1connect" + "github.com/RigelBuild/compass/go/internal/board" "github.com/RigelBuild/compass/go/internal/fabric" "github.com/RigelBuild/compass/go/internal/store" ) @@ -54,6 +55,7 @@ type Comms struct { bus commsBus fabric fabric.EventFabric fabricPublishFailures metric.Int64Counter + boardForges board.Forges // adminID attributes every RPC on the local-socket door (the door has no // interceptor yet). The T3 interceptor overrides this per-request by setting // a caller on the context; adminID is the fallback when none is set. @@ -112,6 +114,11 @@ func (c *Comms) PublishAccountRename(ctx context.Context, account store.Account, } } +// SetBoardForges sets the configured forge coordinates used for board reads. +func (c *Comms) SetBoardForges(forges board.Forges) { + c.boardForges = forges +} + // SetPresenceSource wires the in-memory presence enum source GetRoster joins // against, AFTER both Comms and the hub exist — a post-construction setter that // breaks the comms<->hub construction cycle (comms is built before the hub diff --git a/go/internal/comms/roster.go b/go/internal/comms/roster.go index 914cb3365..c0c674aa2 100644 --- a/go/internal/comms/roster.go +++ b/go/internal/comms/roster.go @@ -3,11 +3,13 @@ package comms import ( "context" "errors" + "log/slog" "time" "connectrpc.com/connect" compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/board" "github.com/RigelBuild/compass/go/internal/store" ) @@ -81,7 +83,14 @@ func (c *Comms) roster( if err != nil { return nil, edgeError(err) } - + boards, err := c.store.AgentBoards(ctx, ids) + if err != nil { + return nil, edgeError(err) + } + summaries := make(map[store.AccountID]*compassv1.AgentBoardSummary, len(boards)) + for id, row := range boards { + summaries[id] = c.boardSummary(ctx, row, presence) + } entries := make([]*compassv1.RosterEntry, 0, len(clipped)) for _, acc := range clipped { pres := compassv1.AgentPresence_AGENT_PRESENCE_OFFLINE @@ -101,11 +110,70 @@ func (c *Comms) roster( Presence: pres, Activity: act.Activity, ActivityAtUnixMs: act.ActivityAtUnixMs, + Board: summaries[acc.ID], }) } return entries, nil } +func (c *Comms) boardSummary(ctx context.Context, row store.BoardRow, presence map[store.AccountID]compassv1.AgentPresence) *compassv1.AgentBoardSummary { + summary := &compassv1.AgentBoardSummary{ + TaskCount: uint32(min(len(row.Tasks), store.MaxBoardTasks)), Halt: haltToWire(row.Halt), WrittenAtUnixMs: row.WrittenAtUnixMs, + } + allRefs := make([]string, 0, len(row.Tasks)*2) + taskRefRanges := make([][2]int, len(row.Tasks)) + waitingOnOperator := make([]bool, len(row.Tasks)) + for i, task := range row.Tasks { + if task.Key == row.Focus { + summary.Focus = task.PrimaryRef + if summary.GetFocus() != "" { + summary.Focus += " " + } + summary.Focus += task.Title + } + start := len(allRefs) + allRefs = append(allRefs, task.Refs...) + if task.PrimaryRef != "" { + allRefs = append(allRefs, task.PrimaryRef) + } + taskRefRanges[i] = [2]int{start, len(allRefs)} + waiting, err := unmarshalBoardWaiting(task.Waiting) + if err != nil { + slog.WarnContext(ctx, "comms: board summary decode failed", "agent_account_id", string(row.Agent), "task_key", task.Key, "error", err) + continue + } + for _, entry := range waiting { + if entry.GetOn() == compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR { + waitingOnOperator[i] = true + break + } + } + } + presenceValue := presence[row.Agent] + summary.Stale = presenceValue == compassv1.AgentPresence_AGENT_PRESENCE_WORKING && row.WrittenAtUnixMs < time.Now().Add(-boardStaleAfter).UnixMilli() + if len(allRefs) != 0 { + derived, _, err := board.DeriveRefs(ctx, c.store, c.boardForges, row.Agent, allRefs) + if err != nil { + slog.WarnContext(ctx, "comms: board summary PR gates unavailable", "agent_account_id", string(row.Agent), "error", err) + } else { + for i := range row.Tasks { + for _, ref := range derived[taskRefRanges[i][0]:taskRefRanges[i][1]] { + if ref.GetGate() == compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW || ref.GetGate() == compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_ENQUEUE { + waitingOnOperator[i] = true + break + } + } + } + } + } + for _, waiting := range waitingOnOperator { + if waiting { + summary.WaitingOnOperator++ + } + } + return summary +} + // treeForScope selects the tree read for scope, relative to the vantage agent // (design.md T2:426-452). NEIGHBORHOOD → parent+siblings+children; SUBTREE → // vantage + descendants; OWNER → every agent owned by the vantage's owner. A diff --git a/go/internal/comms/roster_board_pgtest_test.go b/go/internal/comms/roster_board_pgtest_test.go new file mode 100644 index 000000000..ae6ced73f --- /dev/null +++ b/go/internal/comms/roster_board_pgtest_test.go @@ -0,0 +1,210 @@ +//go:build pgtest + +package comms + +import ( + "context" + "testing" + "time" + + "connectrpc.com/connect" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/board" + "github.com/RigelBuild/compass/go/internal/store" + "google.golang.org/protobuf/encoding/protojson" +) + +func TestGetRosterBoardSummaryCountsOperatorGatesAndStaleness(t *testing.T) { + svc, st := newHandler(t) + ctx := context.Background() + owner := mustUser(t, st, "board-summary-owner") + agent := mustAgent(t, st, owner.ID, "board-summary-agent") + fresh := mustAgent(t, st, owner.ID, "board-summary-fresh") + oldOffline := mustAgent(t, st, owner.ID, "board-summary-old-offline") + + const writtenAt = int64(1_700_000_000_000) + prs := []*compassv1.PullRequest{ + {Forge: &compassv1.ForgeRef{Provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, Host: "github.com"}, Repo: "owner/repo", Number: 12, Title: "Review me", ForgeState: "open", Checks: &compassv1.ChecksSummary{State: "success"}}, + {Forge: &compassv1.ForgeRef{Provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, Host: "github.com"}, Repo: "owner/repo", Number: 13, Title: "Queue me", ForgeState: "open", HeadSha: "head-13", Checks: &compassv1.ChecksSummary{State: "success"}, Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "approved", CommitId: "head-13"}}}, + } + prAt := time.Now().UTC() + for _, pr := range prs { + prJSON, err := protojson.Marshal(pr) + if err != nil { + t.Fatalf("marshal pull request %d: %v", pr.GetNumber(), err) + } + if _, err := st.UpsertPullRequest(ctx, store.PullRequestRow{ + Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: uint64(pr.GetNumber())}, + State: "open", CreatedAt: prAt, UpdatedAt: prAt, PR: prJSON, + }, nil); err != nil { + t.Fatalf("UpsertPullRequest(%d): %v", pr.GetNumber(), err) + } + } + state := compassv1.BoardTaskState_BOARD_TASK_STATE_ACTIVE + for _, target := range []struct { + account store.Account + patch *compassv1.UpdateAgentBoardRequest + }{ + {account: agent, patch: &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{ + {Key: "focus", Title: new("Review task"), PrimaryRef: new("owner/repo#12"), State: &state, + Waiting: &compassv1.BoardWaitingOp{Values: []*compassv1.BoardWaiting{{On: compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR}}}}, + {Key: "enqueue", Title: new("Enqueue task"), PrimaryRef: new("owner/repo#13"), State: &state}, + {Key: "operator", Title: new("Operator task"), State: &state, Waiting: &compassv1.BoardWaitingOp{Values: []*compassv1.BoardWaiting{{On: compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR}}}}, + }, FocusTaskKey: new("focus"), TurnSequence: 1, + }}, + {account: fresh, patch: &compassv1.UpdateAgentBoardRequest{UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "fresh", Title: new("Fresh board"), State: &state}}}}, + {account: oldOffline, patch: &compassv1.UpdateAgentBoardRequest{UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "old", Title: new("Old offline board"), State: &state}}}}, + } { + if _, err := svc.UpdateBoardAsAccount(ctx, target.account.ID, target.patch); err != nil { + t.Fatalf("UpdateBoardAsAccount(%s): %v", target.account.Handle, err) + } + } + if _, err := st.ApplyBoardPatch(ctx, agent.ID, store.BoardPatch{NowUnixMs: writtenAt}); err != nil { + t.Fatalf("ApplyBoardPatch(at old time): %v", err) + } + if _, err := st.ApplyBoardPatch(ctx, oldOffline.ID, store.BoardPatch{NowUnixMs: writtenAt}); err != nil { + t.Fatalf("ApplyBoardPatch(old offline at old time): %v", err) + } + svc.SetPresenceSource(fakePresenceSource{presence: map[store.AccountID]compassv1.AgentPresence{ + agent.ID: compassv1.AgentPresence_AGENT_PRESENCE_WORKING, + fresh.ID: compassv1.AgentPresence_AGENT_PRESENCE_WORKING, + }}) + svc.SetBoardForges(board.Forges{GitHubHost: "github.com"}) + resp, err := svc.GetRoster(WithActor(ctx, owner.ID), connect.NewRequest(&compassv1.GetRosterRequest{ + Scope: compassv1.RosterScope_ROSTER_SCOPE_OWNER, VantageHandle: agent.Handle, + })) + if err != nil { + t.Fatalf("GetRoster: %v", err) + } + entries := rosterByID(resp.Msg.GetEntries()) + summary := entries[string(agent.ID)].GetBoard() + if summary == nil || summary.GetFocus() != "owner/repo#12 Review task" || summary.GetTaskCount() != 3 || summary.GetWaitingOnOperator() != 3 { + t.Fatalf("board summary = %v, want focus, three tasks, and three distinct gate waits", summary) + } + if summary.GetWrittenAtUnixMs() != writtenAt || !summary.GetStale() { + t.Fatalf("working board freshness = %v, want written_at %d and stale=true", summary, writtenAt) + } + if got := entries[string(fresh.ID)].GetBoard(); got == nil || got.GetStale() { + t.Fatalf("fresh working board = %v, want stale=false", got) + } + if got := entries[string(oldOffline.ID)].GetBoard(); got == nil || got.GetStale() { + t.Fatalf("old offline board = %v, want stale=false", got) + } +} + +func TestBoardSummaryStalenessUsesStrictThirtyMinuteBoundary(t *testing.T) { + svc, st := newHandler(t) + ctx := t.Context() + owner := mustUser(t, st, "board-summary-boundary-owner") + accounts := []store.Account{ + mustAgent(t, st, owner.ID, "board-summary-29-minutes"), + mustAgent(t, st, owner.ID, "board-summary-31-minutes"), + } + for _, account := range accounts { + if _, err := svc.UpdateBoardAsAccount(ctx, account.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Title: new("Task")}}, + }); err != nil { + t.Fatalf("UpdateBoardAsAccount(%s): %v", account.Handle, err) + } + } + if _, err := st.ApplyBoardPatch(ctx, accounts[0].ID, store.BoardPatch{NowUnixMs: time.Now().Add(-29 * time.Minute).UnixMilli()}); err != nil { + t.Fatalf("ApplyBoardPatch(29 minutes): %v", err) + } + if _, err := st.ApplyBoardPatch(ctx, accounts[1].ID, store.BoardPatch{NowUnixMs: time.Now().Add(-31 * time.Minute).UnixMilli()}); err != nil { + t.Fatalf("ApplyBoardPatch(31 minutes): %v", err) + } + svc.SetPresenceSource(fakePresenceSource{presence: map[store.AccountID]compassv1.AgentPresence{ + accounts[0].ID: compassv1.AgentPresence_AGENT_PRESENCE_WORKING, + accounts[1].ID: compassv1.AgentPresence_AGENT_PRESENCE_WORKING, + }}) + resp, err := svc.GetRoster(WithActor(ctx, owner.ID), connect.NewRequest(&compassv1.GetRosterRequest{ + Scope: compassv1.RosterScope_ROSTER_SCOPE_OWNER, + })) + if err != nil { + t.Fatalf("GetRoster: %v", err) + } + entries := rosterByID(resp.Msg.GetEntries()) + for i, wantStale := range []bool{false, true} { + summary := entries[string(accounts[i].ID)].GetBoard() + if summary == nil { + t.Fatalf("%d-minute board summary is missing", []int{29, 31}[i]) + } + if summary.GetStale() != wantStale { + t.Fatalf("%d-minute board stale = %t, want %t", []int{29, 31}[i], summary.GetStale(), wantStale) + } + } +} + +func TestGetRosterKeepsSummariesWhenBoardRefDerivationFails(t *testing.T) { + svc, st := newHandler(t) + ctx := t.Context() + owner := mustUser(t, st, "board-summary-isolation-owner") + bad := mustAgent(t, st, owner.ID, "board-summary-isolation-bad") + good := mustAgent(t, st, owner.ID, "board-summary-isolation-good") + if _, err := svc.UpdateBoardAsAccount(ctx, bad.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "bad", Title: new("Bad board"), PrimaryRef: new("owner/repo#12")}}, + }); err != nil { + t.Fatalf("UpdateBoardAsAccount(bad): %v", err) + } + if _, err := st.ApplyBoardPatch(ctx, good.ID, store.BoardPatch{NowUnixMs: 1}); err != nil { + t.Fatalf("ApplyBoardPatch(good): %v", err) + } + if _, err := st.UpsertPullRequest(ctx, store.PullRequestRow{ + Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 12}, + State: "open", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), PR: []byte(`{"number":"invalid"}`), + }, nil); err != nil { + t.Fatalf("UpsertPullRequest(invalid protobuf JSON): %v", err) + } + svc.SetBoardForges(board.Forges{GitHubHost: "github.com"}) + resp, err := svc.GetRoster(WithActor(ctx, owner.ID), connect.NewRequest(&compassv1.GetRosterRequest{ + Scope: compassv1.RosterScope_ROSTER_SCOPE_OWNER, + })) + if err != nil { + t.Fatalf("GetRoster: %v", err) + } + entries := rosterByID(resp.Msg.GetEntries()) + badEntry := entries[string(bad.ID)] + if badEntry == nil || badEntry.GetBoard() == nil || badEntry.GetBoard().GetTaskCount() != 1 { + t.Fatalf("malformed-ref board entry = %v, want roster row with its stored task summary", badEntry) + } + if entries[string(good.ID)] == nil || entries[string(good.ID)].GetBoard() == nil { + t.Fatalf("valid board entry = %v, want an independent summary", entries[string(good.ID)]) + } +} + +func TestBoardSummaryKeepsStoredCountsWhenRefDerivationFails(t *testing.T) { + svc, st := newHandler(t) + ctx := t.Context() + owner := mustUser(t, st, "board-summary-derive-owner") + agent := mustAgent(t, st, owner.ID, "board-summary-derive-agent") + if _, err := svc.UpdateBoardAsAccount(ctx, agent.ID, &compassv1.UpdateAgentBoardRequest{ + UpsertTasks: []*compassv1.BoardTaskPatch{{Key: "task", Title: new("Stored task"), PrimaryRef: new("owner/repo#12"), Waiting: &compassv1.BoardWaitingOp{Values: []*compassv1.BoardWaiting{{On: compassv1.BoardWaitingOn_BOARD_WAITING_ON_OPERATOR}}}}}, + }); err != nil { + t.Fatalf("UpdateBoardAsAccount: %v", err) + } + if _, err := st.ApplyBoardPatch(ctx, agent.ID, store.BoardPatch{NowUnixMs: time.Now().Add(-31 * time.Minute).UnixMilli()}); err != nil { + t.Fatalf("ApplyBoardPatch(stale timestamp): %v", err) + } + if _, err := st.UpsertPullRequest(ctx, store.PullRequestRow{ + Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 12}, + State: "open", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), PR: []byte(`{"number":"invalid"}`), + }, nil); err != nil { + t.Fatalf("UpsertPullRequest(invalid protobuf JSON): %v", err) + } + svc.SetBoardForges(board.Forges{GitHubHost: "github.com"}) + svc.SetPresenceSource(fakePresenceSource{presence: map[store.AccountID]compassv1.AgentPresence{ + agent.ID: compassv1.AgentPresence_AGENT_PRESENCE_WORKING, + }}) + resp, err := svc.GetRoster(WithActor(ctx, owner.ID), connect.NewRequest(&compassv1.GetRosterRequest{ + Scope: compassv1.RosterScope_ROSTER_SCOPE_OWNER, + })) + if err != nil { + t.Fatalf("GetRoster: %v", err) + } + summary := rosterByID(resp.Msg.GetEntries())[string(agent.ID)].GetBoard() + if summary == nil || summary.GetTaskCount() != 1 || summary.GetWaitingOnOperator() != 1 || !summary.GetStale() { + t.Fatalf("stored summary after ref derivation failure = %v, want one stale operator-waiting task", summary) + } +} diff --git a/go/internal/comms/subscribe.go b/go/internal/comms/subscribe.go index 23755dee3..60e12b246 100644 --- a/go/internal/comms/subscribe.go +++ b/go/internal/comms/subscribe.go @@ -61,7 +61,7 @@ func (c *Comms) SubscribeComms( return nil // client hung up before the first frame: a clean end } } - if err := forwardComms(ctx, actor, c.store, sub, stream); err != nil { + if err := forwardComms(ctx, actor, storeEventVisibility{Store: c.store, boardVisibleFn: c.boardVisible}, sub, stream); err != nil { // A store error resolving per-event visibility: the stream ends rather than // failing open (the event is never sent unfiltered), but the client must see // a fault, not a clean EOF. The underlying error is logged, not returned, so @@ -271,6 +271,17 @@ type eventVisibility interface { // that agent — the shared-channel rule matching the fan-out's per-actor // scoping (design.md:487-491). SharesVisibleChannel(ctx context.Context, actor store.AccountID, agent store.AccountID) (bool, error) + // boardVisible gates AgentBoardChanged with the same clip as board reads. + boardVisible(ctx context.Context, actor, agent store.AccountID) (bool, error) +} + +type storeEventVisibility struct { + *store.Store + boardVisibleFn func(context.Context, store.AccountID, store.AccountID) (bool, error) +} + +func (s storeEventVisibility) boardVisible(ctx context.Context, viewer, agent store.AccountID) (bool, error) { + return s.boardVisibleFn(ctx, viewer, agent) } // visibleToActor reports whether actor may receive resp — the per-event D9 gate @@ -316,6 +327,8 @@ func visibleToActor( return vis.AccountVisibleTo(ctx, actor, store.AccountID(p.AccountChanged.GetAccount().GetId())) case *compassv1.SubscribeCommsResponse_AgentWorkspaceChanged: return vis.IsAgentWorkspaceVisible(ctx, actor, store.AccountID(p.AgentWorkspaceChanged.GetWorkspace().GetAgentAccountId())) + case *compassv1.SubscribeCommsResponse_AgentBoardChanged: + return vis.boardVisible(ctx, actor, store.AccountID(p.AgentBoardChanged.GetAgentAccountId())) case *compassv1.SubscribeCommsResponse_AgentPresenceChanged: return vis.SharesVisibleChannel(ctx, actor, store.AccountID(p.AgentPresenceChanged.GetAgentAccountId())) case *compassv1.SubscribeCommsResponse_TopicUpserted: diff --git a/go/internal/comms/subscribe_failclosed_test.go b/go/internal/comms/subscribe_failclosed_test.go index f2a1752ae..75226b602 100644 --- a/go/internal/comms/subscribe_failclosed_test.go +++ b/go/internal/comms/subscribe_failclosed_test.go @@ -88,6 +88,10 @@ func (f fakeVisibility) SharesVisibleChannel(context.Context, store.AccountID, s return false, errUnexpectedVisCall } +func (fakeVisibility) boardVisible(context.Context, store.AccountID, store.AccountID) (bool, error) { + return false, errUnexpectedVisCall +} + // compile-time proof the fake satisfies the seam forwardComms consults. var _ eventVisibility = fakeVisibility{} @@ -376,6 +380,11 @@ func (visibleAlways) SharesVisibleChannel(context.Context, store.AccountID, stor return true, nil } +func (visibleAlways) boardVisible(context.Context, store.AccountID, store.AccountID) (bool, error) { + + return true, nil +} + var _ eventVisibility = visibleAlways{} // overrunSubscription subscribes to a fresh bus, then publishes more than the diff --git a/go/internal/gen/compass/v1/agent_gateway.pb.go b/go/internal/gen/compass/v1/agent_gateway.pb.go index 753991420..ff8160e32 100644 --- a/go/internal/gen/compass/v1/agent_gateway.pb.go +++ b/go/internal/gen/compass/v1/agent_gateway.pb.go @@ -132,6 +132,8 @@ type CommsCallRequest struct { // *CommsCallRequest_CreateChannelGroup // *CommsCallRequest_OpenDm // *CommsCallRequest_ListTopics + // *CommsCallRequest_UpdateBoard + // *CommsCallRequest_Boards Call isCommsCallRequest_Call `protobuf_oneof:"call"` // The W3C `traceparent` of the delivered message that triggered this outbound // call, re-attached by the agent so the Server can LINK the reply's fresh @@ -282,6 +284,24 @@ func (x *CommsCallRequest) GetListTopics() *v1.ListTopicsRequest { return nil } +func (x *CommsCallRequest) GetUpdateBoard() *v1.UpdateAgentBoardRequest { + if x != nil { + if x, ok := x.Call.(*CommsCallRequest_UpdateBoard); ok { + return x.UpdateBoard + } + } + return nil +} + +func (x *CommsCallRequest) GetBoards() *v1.ListAgentBoardsRequest { + if x != nil { + if x, ok := x.Call.(*CommsCallRequest_Boards); ok { + return x.Boards + } + } + return nil +} + func (x *CommsCallRequest) GetTriggerTraceparent() string { if x != nil { return x.TriggerTraceparent @@ -341,6 +361,14 @@ type CommsCallRequest_ListTopics struct { ListTopics *v1.ListTopicsRequest `protobuf:"bytes,12,opt,name=list_topics,json=listTopics,proto3,oneof"` } +type CommsCallRequest_UpdateBoard struct { + UpdateBoard *v1.UpdateAgentBoardRequest `protobuf:"bytes,13,opt,name=update_board,json=updateBoard,proto3,oneof"` +} + +type CommsCallRequest_Boards struct { + Boards *v1.ListAgentBoardsRequest `protobuf:"bytes,14,opt,name=boards,proto3,oneof"` +} + func (*CommsCallRequest_Post) isCommsCallRequest_Call() {} func (*CommsCallRequest_List) isCommsCallRequest_Call() {} @@ -361,11 +389,16 @@ func (*CommsCallRequest_OpenDm) isCommsCallRequest_Call() {} func (*CommsCallRequest_ListTopics) isCommsCallRequest_Call() {} +func (*CommsCallRequest_UpdateBoard) isCommsCallRequest_Call() {} + +func (*CommsCallRequest_Boards) isCommsCallRequest_Call() {} + // The result of one comms call, correlated by `call_id`. A successful call sets // the response arm matching the request's operation (`post`, `list`, `roster`, // `set_status`, `pin`, `create_channel`, `update_members`, -// `create_channel_group`, `open_dm`, `list_topics`); an in-band failure (a tool error — non-member -// channel, bad input) sets `error`, which is NOT a transport teardown. The same +// `create_channel_group`, `open_dm`, `list_topics`, `update_board`, `boards`); +// an in-band failure (a tool error — non-member channel, bad input) sets +// `error`, which is NOT a transport teardown. The same // message is the `RelayCommsCallResponse.result` payload on the Runner->Server // leg. type CommsCallResult struct { @@ -384,6 +417,8 @@ type CommsCallResult struct { // *CommsCallResult_CreateChannelGroup // *CommsCallResult_OpenDm // *CommsCallResult_ListTopics + // *CommsCallResult_UpdateBoard + // *CommsCallResult_Boards Result isCommsCallResult_Result `protobuf_oneof:"result"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -532,6 +567,24 @@ func (x *CommsCallResult) GetListTopics() *v1.ListTopicsResponse { return nil } +func (x *CommsCallResult) GetUpdateBoard() *v1.UpdateAgentBoardResponse { + if x != nil { + if x, ok := x.Result.(*CommsCallResult_UpdateBoard); ok { + return x.UpdateBoard + } + } + return nil +} + +func (x *CommsCallResult) GetBoards() *v1.ListAgentBoardsResponse { + if x != nil { + if x, ok := x.Result.(*CommsCallResult_Boards); ok { + return x.Boards + } + } + return nil +} + type isCommsCallResult_Result interface { isCommsCallResult_Result() } @@ -580,6 +633,14 @@ type CommsCallResult_ListTopics struct { ListTopics *v1.ListTopicsResponse `protobuf:"bytes,12,opt,name=list_topics,json=listTopics,proto3,oneof"` } +type CommsCallResult_UpdateBoard struct { + UpdateBoard *v1.UpdateAgentBoardResponse `protobuf:"bytes,13,opt,name=update_board,json=updateBoard,proto3,oneof"` +} + +type CommsCallResult_Boards struct { + Boards *v1.ListAgentBoardsResponse `protobuf:"bytes,14,opt,name=boards,proto3,oneof"` +} + func (*CommsCallResult_Post) isCommsCallResult_Result() {} func (*CommsCallResult_List) isCommsCallResult_Result() {} @@ -602,6 +663,10 @@ func (*CommsCallResult_OpenDm) isCommsCallResult_Result() {} func (*CommsCallResult_ListTopics) isCommsCallResult_Result() {} +func (*CommsCallResult_UpdateBoard) isCommsCallResult_Result() {} + +func (*CommsCallResult_Boards) isCommsCallResult_Result() {} + // An in-band comms-call failure: a tool error the agent renders to the model, // never a stream teardown. `code` is a short stable token (e.g. "not_found"); // `message` is human-readable detail. @@ -3499,7 +3564,7 @@ var File_compass_v1_agent_gateway_proto protoreflect.FileDescriptor const file_compass_v1_agent_gateway_proto_rawDesc = "" + "\n" + "\x1ecompass/v1/agent_gateway.proto\x12\n" + - "compass.v1\x1a\x16compass/v1/comms.proto\x1a\x16compass/v1/agent.proto\x1a\x18compass/v1/compass.proto\x1a\x16compass/v1/forge.proto\"\xf7\x05\n" + + "compass.v1\x1a\x16compass/v1/comms.proto\x1a\x16compass/v1/agent.proto\x1a\x18compass/v1/compass.proto\x1a\x16compass/v1/forge.proto\"\xff\x06\n" + "\x10CommsCallRequest\x12\x17\n" + "\acall_id\x18\x01 \x01(\tR\x06callId\x124\n" + "\x04post\x18\x02 \x01(\v2\x1e.compass.v1.PostMessageRequestH\x00R\x04post\x125\n" + @@ -3513,10 +3578,12 @@ const file_compass_v1_agent_gateway_proto_rawDesc = "" + "\x14create_channel_group\x18\t \x01(\v2%.compass.v1.CreateChannelGroupRequestH\x00R\x12createChannelGroup\x124\n" + "\aopen_dm\x18\v \x01(\v2\x19.compass.v1.OpenDMRequestH\x00R\x06openDm\x12@\n" + "\vlist_topics\x18\f \x01(\v2\x1d.compass.v1.ListTopicsRequestH\x00R\n" + - "listTopics\x12/\n" + + "listTopics\x12H\n" + + "\fupdate_board\x18\r \x01(\v2#.compass.v1.UpdateAgentBoardRequestH\x00R\vupdateBoard\x12<\n" + + "\x06boards\x18\x0e \x01(\v2\".compass.v1.ListAgentBoardsRequestH\x00R\x06boards\x12/\n" + "\x13trigger_traceparent\x18\n" + " \x01(\tR\x12triggerTraceparentB\x06\n" + - "\x04call\"\x85\x06\n" + + "\x04call\"\x8f\a\n" + "\x0fCommsCallResult\x12\x17\n" + "\acall_id\x18\x01 \x01(\tR\x06callId\x125\n" + "\x04post\x18\x02 \x01(\v2\x1f.compass.v1.PostMessageResponseH\x00R\x04post\x126\n" + @@ -3532,7 +3599,9 @@ const file_compass_v1_agent_gateway_proto_rawDesc = "" + " \x01(\v2&.compass.v1.CreateChannelGroupResponseH\x00R\x12createChannelGroup\x125\n" + "\aopen_dm\x18\v \x01(\v2\x1a.compass.v1.OpenDMResponseH\x00R\x06openDm\x12A\n" + "\vlist_topics\x18\f \x01(\v2\x1e.compass.v1.ListTopicsResponseH\x00R\n" + - "listTopicsB\b\n" + + "listTopics\x12I\n" + + "\fupdate_board\x18\r \x01(\v2$.compass.v1.UpdateAgentBoardResponseH\x00R\vupdateBoard\x12=\n" + + "\x06boards\x18\x0e \x01(\v2#.compass.v1.ListAgentBoardsResponseH\x00R\x06boardsB\b\n" + "\x06result\">\n" + "\x0eCommsCallError\x12\x12\n" + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + @@ -3790,24 +3859,28 @@ var file_compass_v1_agent_gateway_proto_goTypes = []any{ (*v1.CreateChannelGroupRequest)(nil), // 51: compass.v1.CreateChannelGroupRequest (*v1.OpenDMRequest)(nil), // 52: compass.v1.OpenDMRequest (*v1.ListTopicsRequest)(nil), // 53: compass.v1.ListTopicsRequest - (*v1.PostMessageResponse)(nil), // 54: compass.v1.PostMessageResponse - (*v1.ListMessagesResponse)(nil), // 55: compass.v1.ListMessagesResponse - (*v1.GetRosterResponse)(nil), // 56: compass.v1.GetRosterResponse - (*v1.UpdatePinnedBoardResponse)(nil), // 57: compass.v1.UpdatePinnedBoardResponse - (*v1.CreateChannelResponse)(nil), // 58: compass.v1.CreateChannelResponse - (*v1.UpdateChannelMembersResponse)(nil), // 59: compass.v1.UpdateChannelMembersResponse - (*v1.CreateChannelGroupResponse)(nil), // 60: compass.v1.CreateChannelGroupResponse - (*v1.OpenDMResponse)(nil), // 61: compass.v1.OpenDMResponse - (*v1.ListTopicsResponse)(nil), // 62: compass.v1.ListTopicsResponse - (*v1.ForgeRef)(nil), // 63: compass.v1.ForgeRef - (*v1.Issue)(nil), // 64: compass.v1.Issue - (*CommentRef)(nil), // 65: compass.v1.CommentRef - (*v1.PullRequest)(nil), // 66: compass.v1.PullRequest - (*ReviewRef)(nil), // 67: compass.v1.ReviewRef - (ForgeArtifactKind)(0), // 68: compass.v1.ForgeArtifactKind - (v1.IssueState)(0), // 69: compass.v1.IssueState - (*AgentFrame)(nil), // 70: compass.v1.AgentFrame - (*AgentControl)(nil), // 71: compass.v1.AgentControl + (*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 } var file_compass_v1_agent_gateway_proto_depIdxs = []int32{ 45, // 0: compass.v1.CommsCallRequest.post:type_name -> compass.v1.PostMessageRequest @@ -3820,78 +3893,82 @@ var file_compass_v1_agent_gateway_proto_depIdxs = []int32{ 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.CommsCallResult.post:type_name -> compass.v1.PostMessageResponse - 55, // 11: compass.v1.CommsCallResult.list:type_name -> compass.v1.ListMessagesResponse - 3, // 12: compass.v1.CommsCallResult.error:type_name -> compass.v1.CommsCallError - 56, // 13: compass.v1.CommsCallResult.roster:type_name -> compass.v1.GetRosterResponse - 5, // 14: compass.v1.CommsCallResult.set_status:type_name -> compass.v1.SetAgentStatusResponse - 57, // 15: compass.v1.CommsCallResult.pin:type_name -> compass.v1.UpdatePinnedBoardResponse - 58, // 16: compass.v1.CommsCallResult.create_channel:type_name -> compass.v1.CreateChannelResponse - 59, // 17: compass.v1.CommsCallResult.update_members:type_name -> compass.v1.UpdateChannelMembersResponse - 60, // 18: compass.v1.CommsCallResult.create_channel_group:type_name -> compass.v1.CreateChannelGroupResponse - 61, // 19: compass.v1.CommsCallResult.open_dm:type_name -> compass.v1.OpenDMResponse - 62, // 20: compass.v1.CommsCallResult.list_topics:type_name -> compass.v1.ListTopicsResponse - 7, // 21: compass.v1.LifecycleCallRequest.spawn:type_name -> compass.v1.SpawnPeerRequest - 9, // 22: compass.v1.LifecycleCallRequest.despawn:type_name -> compass.v1.DespawnPeerRequest - 8, // 23: compass.v1.LifecycleCallResult.spawn:type_name -> compass.v1.SpawnPeerResponse - 10, // 24: compass.v1.LifecycleCallResult.despawn:type_name -> compass.v1.DespawnPeerResponse - 12, // 25: compass.v1.LifecycleCallResult.error:type_name -> compass.v1.LifecycleCallError - 16, // 26: compass.v1.ForgeCallRequest.create_issue:type_name -> compass.v1.CreateIssueRequest - 17, // 27: compass.v1.ForgeCallRequest.comment_on_issue:type_name -> compass.v1.CommentOnIssueRequest - 18, // 28: compass.v1.ForgeCallRequest.get_issue:type_name -> compass.v1.GetIssueRequest - 19, // 29: compass.v1.ForgeCallRequest.list_issues:type_name -> compass.v1.ListIssuesRequest - 21, // 30: compass.v1.ForgeCallRequest.create_pull_request:type_name -> compass.v1.CreatePullRequestRequest - 23, // 31: compass.v1.ForgeCallRequest.comment_on_pull_request:type_name -> compass.v1.CommentOnPullRequestRequest - 24, // 32: compass.v1.ForgeCallRequest.get_pull_request:type_name -> compass.v1.GetPullRequestRequest - 29, // 33: compass.v1.ForgeCallRequest.subscribe:type_name -> compass.v1.SubscribeForgeRequest - 31, // 34: compass.v1.ForgeCallRequest.unsubscribe:type_name -> compass.v1.UnsubscribeForgeRequest - 25, // 35: compass.v1.ForgeCallRequest.submit_review:type_name -> compass.v1.SubmitReviewRequest - 27, // 36: compass.v1.ForgeCallRequest.transition_issue_state:type_name -> compass.v1.TransitionIssueStateRequest - 28, // 37: compass.v1.ForgeCallRequest.transition_pull_request_state:type_name -> compass.v1.TransitionPullRequestStateRequest - 63, // 38: compass.v1.ForgeCallRequest.forge:type_name -> compass.v1.ForgeRef - 64, // 39: compass.v1.ForgeCallResult.issue:type_name -> compass.v1.Issue - 65, // 40: compass.v1.ForgeCallResult.issue_comment:type_name -> compass.v1.CommentRef - 20, // 41: compass.v1.ForgeCallResult.issues:type_name -> compass.v1.ListIssuesResponse - 66, // 42: compass.v1.ForgeCallResult.pull_request:type_name -> compass.v1.PullRequest - 65, // 43: compass.v1.ForgeCallResult.pr_comment:type_name -> compass.v1.CommentRef - 30, // 44: compass.v1.ForgeCallResult.subscribed:type_name -> compass.v1.SubscribeForgeResponse - 32, // 45: compass.v1.ForgeCallResult.unsubscribed:type_name -> compass.v1.UnsubscribeForgeResponse - 15, // 46: compass.v1.ForgeCallResult.error:type_name -> compass.v1.ForgeCallError - 67, // 47: compass.v1.ForgeCallResult.review:type_name -> compass.v1.ReviewRef - 64, // 48: compass.v1.ListIssuesResponse.issues:type_name -> compass.v1.Issue - 22, // 49: compass.v1.CreatePullRequestRequest.issue:type_name -> compass.v1.PullRequestIssueLink - 63, // 50: compass.v1.PullRequestIssueLink.forge:type_name -> compass.v1.ForgeRef - 26, // 51: compass.v1.SubmitReviewRequest.comments:type_name -> compass.v1.ReviewCommentInput - 68, // 52: compass.v1.SubscribeForgeRequest.kind:type_name -> compass.v1.ForgeArtifactKind - 0, // 53: compass.v1.SubscribeForgeRequest.scope:type_name -> compass.v1.ForgeSubscriptionScope - 34, // 54: compass.v1.BoardCallRequest.set_issue_state:type_name -> compass.v1.SetIssueStateRequest - 69, // 55: compass.v1.SetIssueStateRequest.state:type_name -> compass.v1.IssueState - 64, // 56: compass.v1.SetIssueStateResponse.issue:type_name -> compass.v1.Issue - 35, // 57: compass.v1.BoardCallResult.set_issue_state:type_name -> compass.v1.SetIssueStateResponse - 37, // 58: compass.v1.BoardCallResult.error:type_name -> compass.v1.BoardCallError - 70, // 59: compass.v1.PublishFrameRequest.frame:type_name -> compass.v1.AgentFrame - 70, // 60: compass.v1.PostConversationFrameRequest.frame:type_name -> compass.v1.AgentFrame - 1, // 61: compass.v1.AgentGateway.Comms:input_type -> compass.v1.CommsCallRequest - 6, // 62: compass.v1.AgentGateway.Lifecycle:input_type -> compass.v1.LifecycleCallRequest - 38, // 63: compass.v1.AgentGateway.Publish:input_type -> compass.v1.PublishFrameRequest - 40, // 64: compass.v1.AgentGateway.PostConversationFrame:input_type -> compass.v1.PostConversationFrameRequest - 42, // 65: compass.v1.AgentGateway.Control:input_type -> compass.v1.ControlSubscribeRequest - 13, // 66: compass.v1.AgentGateway.Forge:input_type -> compass.v1.ForgeCallRequest - 33, // 67: compass.v1.AgentGateway.Board:input_type -> compass.v1.BoardCallRequest - 43, // 68: compass.v1.AgentGateway.PutSessionBlob:input_type -> compass.v1.PutSessionBlobRequest - 2, // 69: compass.v1.AgentGateway.Comms:output_type -> compass.v1.CommsCallResult - 11, // 70: compass.v1.AgentGateway.Lifecycle:output_type -> compass.v1.LifecycleCallResult - 39, // 71: compass.v1.AgentGateway.Publish:output_type -> compass.v1.PublishFrameResponse - 41, // 72: compass.v1.AgentGateway.PostConversationFrame:output_type -> compass.v1.PostConversationFrameResponse - 71, // 73: compass.v1.AgentGateway.Control:output_type -> compass.v1.AgentControl - 14, // 74: compass.v1.AgentGateway.Forge:output_type -> compass.v1.ForgeCallResult - 36, // 75: compass.v1.AgentGateway.Board:output_type -> compass.v1.BoardCallResult - 44, // 76: compass.v1.AgentGateway.PutSessionBlob:output_type -> compass.v1.PutSessionBlobResponse - 69, // [69:77] is the sub-list for method output_type - 61, // [61:69] is the sub-list for method input_type - 61, // [61:61] is the sub-list for extension type_name - 61, // [61:61] is the sub-list for extension extendee - 0, // [0:61] is the sub-list for field type_name + 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 + 3, // 14: compass.v1.CommsCallResult.error:type_name -> compass.v1.CommsCallError + 58, // 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 + 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 } func init() { file_compass_v1_agent_gateway_proto_init() } @@ -3912,6 +3989,8 @@ func file_compass_v1_agent_gateway_proto_init() { (*CommsCallRequest_CreateChannelGroup)(nil), (*CommsCallRequest_OpenDm)(nil), (*CommsCallRequest_ListTopics)(nil), + (*CommsCallRequest_UpdateBoard)(nil), + (*CommsCallRequest_Boards)(nil), } file_compass_v1_agent_gateway_proto_msgTypes[1].OneofWrappers = []any{ (*CommsCallResult_Post)(nil), @@ -3925,6 +4004,8 @@ func file_compass_v1_agent_gateway_proto_init() { (*CommsCallResult_CreateChannelGroup)(nil), (*CommsCallResult_OpenDm)(nil), (*CommsCallResult_ListTopics)(nil), + (*CommsCallResult_UpdateBoard)(nil), + (*CommsCallResult_Boards)(nil), } file_compass_v1_agent_gateway_proto_msgTypes[5].OneofWrappers = []any{ (*LifecycleCallRequest_Spawn)(nil), diff --git a/go/internal/runnerhub/helpers_test.go b/go/internal/runnerhub/helpers_test.go index 52789c9d7..3607043e5 100644 --- a/go/internal/runnerhub/helpers_test.go +++ b/go/internal/runnerhub/helpers_test.go @@ -184,13 +184,15 @@ func bindSession(hub *Hub, sessionID string) { // the fake's proof of WHICH account attribution the hub applied — the security // invariant the RelayCommsCall tests defend. type commsCall struct { - account store.AccountID - post *compassv1.PostMessageRequest - list *compassv1.ListMessagesRequest - topicList *compassv1.ListTopicsRequest - roster *compassv1.GetRosterRequest - setStatus string - pin *compassv1.UpdatePinnedBoardRequest + account store.AccountID + post *compassv1.PostMessageRequest + list *compassv1.ListMessagesRequest + topicList *compassv1.ListTopicsRequest + roster *compassv1.GetRosterRequest + setStatus string + pin *compassv1.UpdatePinnedBoardRequest + updateBoard *compassv1.UpdateAgentBoardRequest + boards *compassv1.ListAgentBoardsRequest createChannel *compassv1.CreateChannelRequest updateMembers *compassv1.UpdateChannelMembersRequest @@ -226,6 +228,10 @@ type fakeCommsCaller struct { setStatusReturned string pinResp *compassv1.UpdatePinnedBoardResponse pinErr error + updateBoardResp *compassv1.UpdateAgentBoardResponse + updateBoardErr error + boardsResp *compassv1.ListAgentBoardsResponse + boardsErr error createChannelResp *compassv1.CreateChannelResponse createChannelErr error @@ -323,6 +329,20 @@ func (f *fakeCommsCaller) UpdatePinnedBoardAsAccount(_ context.Context, account return f.pinResp, nil } +func (f *fakeCommsCaller) UpdateBoardAsAccount(_ context.Context, account store.AccountID, req *compassv1.UpdateAgentBoardRequest) (*compassv1.UpdateAgentBoardResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, commsCall{account: account, updateBoard: req}) + return f.updateBoardResp, f.updateBoardErr +} + +func (f *fakeCommsCaller) ListBoardsAsAccount(_ context.Context, account store.AccountID, req *compassv1.ListAgentBoardsRequest) (*compassv1.ListAgentBoardsResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, commsCall{account: account, boards: req}) + return f.boardsResp, f.boardsErr +} + func (f *fakeCommsCaller) CreateChannelAsAccount(_ context.Context, account store.AccountID, req *compassv1.CreateChannelRequest) (*compassv1.CreateChannelResponse, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/go/internal/runnerhub/hub.go b/go/internal/runnerhub/hub.go index 006217748..e8ec5f2b1 100644 --- a/go/internal/runnerhub/hub.go +++ b/go/internal/runnerhub/hub.go @@ -342,6 +342,9 @@ type CommsCaller interface { //nolint:interfacebloat // one method per agent-com // returning the server-truncated value that landed in the table — the write // half of the set_status ordered write-then-publish (RIG-1721 T2 / T3). SetStatusAsAccount(ctx context.Context, account store.AccountID, activity string) (string, error) + // UpdateBoardAsAccount applies a presence-aware board patch for account. + UpdateBoardAsAccount(ctx context.Context, account store.AccountID, req *compassv1.UpdateAgentBoardRequest) (*compassv1.UpdateAgentBoardResponse, error) + ListBoardsAsAccount(ctx context.Context, account store.AccountID, req *compassv1.ListAgentBoardsRequest) (*compassv1.ListAgentBoardsResponse, error) UpdatePinnedBoardAsAccount(ctx context.Context, account store.AccountID, req *compassv1.UpdatePinnedBoardRequest) (*compassv1.UpdatePinnedBoardResponse, error) CreateChannelAsAccount(ctx context.Context, account store.AccountID, req *compassv1.CreateChannelRequest) (*compassv1.CreateChannelResponse, error) // UpdateChannelMembersAsAccountByName is the agent-tool path: channel_id is a diff --git a/go/internal/runnerhub/relay_arm_coverage_test.go b/go/internal/runnerhub/relay_arm_coverage_test.go index 7b38d0326..0dbc9d48a 100644 --- a/go/internal/runnerhub/relay_arm_coverage_test.go +++ b/go/internal/runnerhub/relay_arm_coverage_test.go @@ -25,6 +25,25 @@ func relayPin(sessionID, callID string, req *compassv1.UpdatePinnedBoardRequest) }, } } +func relayUpdateBoard(sessionID, callID string, req *compassv1.UpdateAgentBoardRequest) *compassv1internal.RelayCommsCallRequest { + return &compassv1internal.RelayCommsCallRequest{ + SessionId: sessionID, + Call: &compassv1internal.CommsCallRequest{ + CallId: callID, + Call: &compassv1internal.CommsCallRequest_UpdateBoard{UpdateBoard: req}, + }, + } +} + +func relayBoards(sessionID, callID string, req *compassv1.ListAgentBoardsRequest) *compassv1internal.RelayCommsCallRequest { + return &compassv1internal.RelayCommsCallRequest{ + SessionId: sessionID, + Call: &compassv1internal.CommsCallRequest{ + CallId: callID, + Call: &compassv1internal.CommsCallRequest_Boards{Boards: req}, + }, + } +} // TestRelayCommsPinDispatchesAsBoundAccount: a pin call forwards the exact // request under the bound account and wraps the pin result with call_id intact. @@ -111,26 +130,20 @@ type armCase struct { // than inside it so the table reads as data and the assertions read as logic; // commsCallOneofArms gates it against the oneof so a new arm cannot be missed. func commsArmCases() []armCase { + return append(commsArmCasesCore(), commsArmCasesBoardAndChannels()...) +} + +func commsArmCasesCore() []armCase { post := &compassv1.PostMessageRequest{} list := &compassv1.ListMessagesRequest{} listTopics := &compassv1.ListTopicsRequest{} roster := &compassv1.GetRosterRequest{} pin := &compassv1.UpdatePinnedBoardRequest{} - createChannel := &compassv1.CreateChannelRequest{} - updateMembers := &compassv1.UpdateChannelMembersRequest{} - createChannelGroup := &compassv1.CreateChannelGroupRequest{} - openDM := &compassv1.OpenDMRequest{} - postResp := &compassv1.PostMessageResponse{} listResp := &compassv1.ListMessagesResponse{} listTopicsResp := &compassv1.ListTopicsResponse{} rosterResp := &compassv1.GetRosterResponse{} pinResp := &compassv1.UpdatePinnedBoardResponse{} - createChannelResp := &compassv1.CreateChannelResponse{} - updateMembersResp := &compassv1.UpdateChannelMembersResponse{} - createChannelGroupResp := &compassv1.CreateChannelGroupResponse{} - openDMResp := &compassv1.OpenDMResponse{} - return []armCase{ { name: "post", @@ -190,6 +203,23 @@ func commsArmCases() []armCase { gotResp: func(r *compassv1internal.CommsCallResult) any { return r.GetPin() }, wantResp: pinResp, }, + } +} + +func commsArmCasesBoardAndChannels() []armCase { + createChannel := &compassv1.CreateChannelRequest{} + updateMembers := &compassv1.UpdateChannelMembersRequest{} + updateBoard := &compassv1.UpdateAgentBoardRequest{} + boards := &compassv1.ListAgentBoardsRequest{} + createChannelGroup := &compassv1.CreateChannelGroupRequest{} + openDM := &compassv1.OpenDMRequest{} + createChannelResp := &compassv1.CreateChannelResponse{} + updateMembersResp := &compassv1.UpdateChannelMembersResponse{} + updateBoardResp := &compassv1.UpdateAgentBoardResponse{} + boardsResp := &compassv1.ListAgentBoardsResponse{} + createChannelGroupResp := &compassv1.CreateChannelGroupResponse{} + openDMResp := &compassv1.OpenDMResponse{} + return []armCase{ { name: "create_channel", request: relayCreateChannel("sess-1", "tc-channel", createChannel), @@ -208,6 +238,24 @@ func commsArmCases() []armCase { gotResp: func(r *compassv1internal.CommsCallResult) any { return r.GetUpdateMembers() }, wantResp: updateMembersResp, }, + { + name: "update_board", + request: relayUpdateBoard("sess-1", "tc-board", updateBoard), + seed: func(c *fakeCommsCaller) { c.updateBoardResp = updateBoardResp }, + field: func(c commsCall) any { return c.updateBoard }, + want: updateBoard, + gotResp: func(r *compassv1internal.CommsCallResult) any { return r.GetUpdateBoard() }, + wantResp: updateBoardResp, + }, + { + name: "boards", + request: relayBoards("sess-1", "tc-boards", boards), + seed: func(c *fakeCommsCaller) { c.boardsResp = boardsResp }, + field: func(c commsCall) any { return c.boards }, + want: boards, + gotResp: func(r *compassv1internal.CommsCallResult) any { return r.GetBoards() }, + wantResp: boardsResp, + }, { name: "create_channel_group", request: relayCreateChannelGroup("sess-1", "tc-group", createChannelGroup), diff --git a/go/internal/runnerhub/relay_comms.go b/go/internal/runnerhub/relay_comms.go index d7d2b98fe..774450cd3 100644 --- a/go/internal/runnerhub/relay_comms.go +++ b/go/internal/runnerhub/relay_comms.go @@ -877,6 +877,22 @@ func (h *Hub) executeCall( return &compassv1internal.CommsCallResult{ Result: &compassv1internal.CommsCallResult_Pin{Pin: resp}, }, nil + case *compassv1internal.CommsCallRequest_UpdateBoard: + resp, err := h.comms.UpdateBoardAsAccount(ctx, account, c.UpdateBoard) + if err != nil { + return nil, err + } + return &compassv1internal.CommsCallResult{ + Result: &compassv1internal.CommsCallResult_UpdateBoard{UpdateBoard: resp}, + }, nil + case *compassv1internal.CommsCallRequest_Boards: + resp, err := h.comms.ListBoardsAsAccount(ctx, account, c.Boards) + if err != nil { + return nil, err + } + return &compassv1internal.CommsCallResult{ + Result: &compassv1internal.CommsCallResult_Boards{Boards: resp}, + }, nil case *compassv1internal.CommsCallRequest_CreateChannel: resp, err := h.comms.CreateChannelAsAccount(ctx, account, c.CreateChannel) if err != nil { @@ -912,7 +928,7 @@ func (h *Hub) executeCall( default: return nil, connect.NewError( connect.CodeInvalidArgument, - errors.New("runnerhub: comms call has no recognized variant set (post/list/list_topics/roster/set_status/pin/create_channel/update_members/create_channel_group/open_dm)"), + errors.New("runnerhub: comms call has no recognized variant set (post/list/list_topics/roster/set_status/pin/create_channel/update_members/create_channel_group/open_dm/update_board/boards)"), ) } } diff --git a/go/internal/store/agent_boards.go b/go/internal/store/agent_boards.go index 5554ba26a..7a778ed40 100644 --- a/go/internal/store/agent_boards.go +++ b/go/internal/store/agent_boards.go @@ -17,16 +17,37 @@ import ( ) const ( - maxBoardTasks = 30 - maxBoardRefs = 10 - maxBoardWaiting = 5 - maxBoardSubagents = 20 + // MaxBoardTasks is the maximum number of tasks an agent board stores. + MaxBoardTasks = 30 + // MaxBoardRefs is the maximum number of refs on one board task or waiting entry. + MaxBoardRefs = 10 + // MaxBoardWaiting is the maximum number of waiting entries on one board task. + MaxBoardWaiting = 5 + // MaxBoardSubagents is the maximum number of subagents on one board task. + MaxBoardSubagents = 20 + // MaxBoardSearchAgents is the maximum number of agents returned by a board search. + MaxBoardSearchAgents = 100 maxBoardAsks = 20 - maxBoardSearchAgents = 100 - maxBoardTitleChars = 120 - maxBoardNoteChars = 500 - maxBoardHaltChars = 200 - maxBoardWhatChars = 200 + // MaxBoardTaskKeyChars limits task keys. + MaxBoardTaskKeyChars = 64 + // MaxBoardHandleChars limits waiting-entry agent handles. + MaxBoardHandleChars = 64 + // MaxBoardSubagentIDChars limits subagent identifiers. + MaxBoardSubagentIDChars = 64 + // MaxBoardSubagentAgentChars limits subagent agent names. + MaxBoardSubagentAgentChars = 64 + // MaxBoardSubagentDescriptionChars limits subagent descriptions. + MaxBoardSubagentDescriptionChars = 200 + // MaxBoardSubagentStatusChars limits subagent status strings. + MaxBoardSubagentStatusChars = 64 + // MaxBoardTitleChars limits task titles. + MaxBoardTitleChars = 120 + // MaxBoardNoteChars limits task notes. + MaxBoardNoteChars = 500 + // MaxBoardHaltReasonChars limits board halt reasons. + MaxBoardHaltReasonChars = 200 + // MaxBoardWhatChars limits waiting-entry explanations. + MaxBoardWhatChars = 200 ) // BoardTaskPatch updates only fields that are present in the patch. @@ -145,8 +166,8 @@ func (s *Store) ApplyBoardPatch(ctx context.Context, agent AccountID, p BoardPat if err != nil { return BoardRow{}, err } - if len(next.Tasks) > maxBoardTasks { - return BoardRow{}, fmt.Errorf("%w: board has %d tasks; maximum is %d", ErrInvalidArgument, len(next.Tasks), maxBoardTasks) + if len(next.Tasks) > MaxBoardTasks { + return BoardRow{}, fmt.Errorf("%w: board has %d tasks; maximum is %d", ErrInvalidArgument, len(next.Tasks), MaxBoardTasks) } if next.Focus != "" && !boardHasTask(next.Tasks, next.Focus) { return BoardRow{}, fmt.Errorf("%w: focus task %q is not on the board", ErrInvalidArgument, next.Focus) @@ -229,6 +250,9 @@ func mergeBoardPatch(current BoardRow, positions map[string]int32, p BoardPatch) return BoardRow{}, nil, err } if p.Focus != nil { + if err := validateBoardTaskKey(*p.Focus); err != nil { + return BoardRow{}, nil, err + } next.Focus = *p.Focus } if err := applyBoardHalt(&next, p); err != nil { @@ -246,13 +270,16 @@ func applyBoardTaskUpserts(next *BoardRow, positions map[string]int32, p BoardPa if patch.Key == "" { return fmt.Errorf("%w: task key is required", ErrInvalidArgument) } + if err := validateBoardTaskKey(patch.Key); err != nil { + return err + } index := boardTaskIndex(next.Tasks, patch.Key) if index < 0 { if patch.Title == nil { return fmt.Errorf("%w: new task %q requires a title", ErrInvalidArgument, patch.Key) } - if len(next.Tasks) == maxBoardTasks { - return fmt.Errorf("%w: board exceeds %d tasks", ErrInvalidArgument, maxBoardTasks) + if len(next.Tasks) == MaxBoardTasks { + return fmt.Errorf("%w: board exceeds %d tasks", ErrInvalidArgument, MaxBoardTasks) } position, err := nextBoardPosition(positions) if err != nil { @@ -304,6 +331,9 @@ func removeBoardTasks(next *BoardRow, positions map[string]int32, keys []string) if key == "" { return fmt.Errorf("%w: removed task key is required", ErrInvalidArgument) } + if err := validateBoardTaskKey(key); err != nil { + return err + } remove[key] = struct{}{} delete(positions, key) if next.Focus == key { @@ -332,7 +362,6 @@ func nextBoardPosition(positions map[string]int32) (int32, error) { } return highest + 1, nil } - func applyTaskPatch(row BoardTaskRow, patch BoardTaskPatch) (BoardTaskRow, error) { if patch.Title != nil { row.Title = *patch.Title @@ -349,6 +378,14 @@ func applyTaskPatch(row BoardTaskRow, patch BoardTaskPatch) (BoardTaskRow, error if patch.Refs != nil { row.Refs = append([]string(nil), (*patch.Refs)...) } + if err := validateBoardNoNUL("board task primary ref", row.PrimaryRef, ErrInvalidArgument); err != nil { + return BoardTaskRow{}, err + } + for _, ref := range row.Refs { + if err := validateBoardNoNUL("board task ref", ref, ErrInvalidArgument); err != nil { + return BoardTaskRow{}, err + } + } if patch.Waiting != nil { waiting, err := validateWaiting(*patch.Waiting) if err != nil { @@ -370,17 +407,20 @@ func applyTaskPatch(row BoardTaskRow, patch BoardTaskPatch) (BoardTaskRow, error } func validateTask(row BoardTaskRow) error { + if err := validateBoardTaskKey(row.Key); err != nil { + return err + } if row.Title == "" { return fmt.Errorf("%w: task %q title is required", ErrInvalidArgument, row.Key) } - if n := utf8.RuneCountInString(row.Title); n > maxBoardTitleChars { - return fmt.Errorf("%w: task %q title has %d characters; maximum is %d", ErrInvalidArgument, row.Key, n, maxBoardTitleChars) + if err := validateBoardString("board task title", row.Title, MaxBoardTitleChars, ErrInvalidArgument); err != nil { + return err } - if n := utf8.RuneCountInString(row.Note); n > maxBoardNoteChars { - return fmt.Errorf("%w: task %q note has %d characters; maximum is %d", ErrInvalidArgument, row.Key, n, maxBoardNoteChars) + if err := validateBoardString("board task note", row.Note, MaxBoardNoteChars, ErrInvalidArgument); err != nil { + return err } - if len(row.Refs) > maxBoardRefs { - return fmt.Errorf("%w: task %q has %d refs; maximum is %d", ErrInvalidArgument, row.Key, len(row.Refs), maxBoardRefs) + if len(row.Refs) > MaxBoardRefs { + return fmt.Errorf("%w: task %q has %d refs; maximum is %d", ErrInvalidArgument, row.Key, len(row.Refs), MaxBoardRefs) } if row.State < 1 || row.State > 5 { return fmt.Errorf("%w: task %q has invalid state %d", ErrInvalidArgument, row.Key, row.State) @@ -392,20 +432,24 @@ func validateTask(row BoardTaskRow) error { if err != nil { return err } - if len(subagents) > maxBoardSubagents { - return fmt.Errorf("%w: task %q has %d subagents; maximum is %d", ErrInvalidArgument, row.Key, len(subagents), maxBoardSubagents) + if len(subagents) > MaxBoardSubagents { + return fmt.Errorf("%w: task %q has %d subagents; maximum is %d", ErrInvalidArgument, row.Key, len(subagents), MaxBoardSubagents) } return nil } +func validateBoardTaskKey(key string) error { + return validateBoardString("board task key", key, MaxBoardTaskKeyChars, ErrInvalidArgument) +} + func validateHalt(halt BoardHalt) error { if halt.Kind < 0 || halt.Kind > 3 { return fmt.Errorf("%w: invalid halt kind %d", ErrInvalidArgument, halt.Kind) } - if n := utf8.RuneCountInString(halt.Reason); n > maxBoardHaltChars { - return fmt.Errorf("%w: halt reason has %d characters; maximum is %d", ErrInvalidArgument, n, maxBoardHaltChars) + if err := validateBoardNoNUL("board halt ref", halt.Ref, ErrInvalidArgument); err != nil { + return err } - return nil + return validateBoardString("board halt reason", halt.Reason, MaxBoardHaltReasonChars, ErrInvalidArgument) } func validateWaiting(raw []byte) ([]byte, error) { @@ -413,30 +457,12 @@ func validateWaiting(raw []byte) ([]byte, error) { if err := json.Unmarshal(raw, &entries); err != nil || entries == nil { return nil, fmt.Errorf("%w: waiting must be a JSON array", ErrInvalidArgument) } - if len(entries) > maxBoardWaiting { - return nil, fmt.Errorf("%w: task has %d waiting entries; maximum is %d", ErrInvalidArgument, len(entries), maxBoardWaiting) + if len(entries) > MaxBoardWaiting { + return nil, fmt.Errorf("%w: task has %d waiting entries; maximum is %d", ErrInvalidArgument, len(entries), MaxBoardWaiting) } for _, entry := range entries { - var value map[string]json.RawMessage - if err := json.Unmarshal(entry, &value); err != nil || value == nil { - return nil, fmt.Errorf("%w: waiting entry must be an object", ErrInvalidArgument) - } - rawOn, found := value["on"] - var on string - if !found || json.Unmarshal(rawOn, &on) != nil { - return nil, fmt.Errorf("%w: waiting on is required", ErrInvalidArgument) - } - if on != "BOARD_WAITING_ON_OPERATOR" && on != "BOARD_WAITING_ON_AGENT" && on != "BOARD_WAITING_ON_EXTERNAL" { - return nil, fmt.Errorf("%w: waiting on must be a recognized BOARD_WAITING_ON value", ErrInvalidArgument) - } - if rawWhat, found := value["what"]; found { - var what string - if err := json.Unmarshal(rawWhat, &what); err != nil { - return nil, fmt.Errorf("%w: waiting what must be a string", ErrInvalidArgument) - } - if n := utf8.RuneCountInString(what); n > maxBoardWhatChars { - return nil, fmt.Errorf("%w: waiting what has %d characters; maximum is %d", ErrInvalidArgument, n, maxBoardWhatChars) - } + if err := validateWaitingEntry(entry); err != nil { + return nil, err } } if len(entries) == 0 { @@ -445,6 +471,69 @@ func validateWaiting(raw []byte) ([]byte, error) { return append([]byte(nil), raw...), nil } +func validateWaitingEntry(entry json.RawMessage) error { + var value map[string]json.RawMessage + if err := json.Unmarshal(entry, &value); err != nil || value == nil { + return fmt.Errorf("%w: waiting entry must be an object", ErrInvalidArgument) + } + if err := validateOptionalWaitingString(value, "agent_handle", "waiting agent_handle", MaxBoardHandleChars, true); err != nil { + return err + } + if err := validateWaitingOn(value); err != nil { + return err + } + if err := validateOptionalWaitingString(value, "what", "waiting what", MaxBoardWhatChars, true); err != nil { + return err + } + if err := validateOptionalWaitingString(value, "ref", "waiting ref", 0, false); err != nil { + return err + } + return validateWaitingBlocks(value["blocks"]) +} + +func validateOptionalWaitingString(value map[string]json.RawMessage, key, label string, maxChars int, bounded bool) error { + raw, found := value[key] + if !found { + return nil + } + var text string + if err := json.Unmarshal(raw, &text); err != nil { + return fmt.Errorf("%w: %s must be a string", ErrInvalidArgument, key) + } + if bounded { + return validateBoardString(label, text, maxChars, ErrInvalidArgument) + } + return validateBoardNoNUL(label, text, ErrInvalidArgument) +} + +func validateWaitingOn(value map[string]json.RawMessage) error { + raw, found := value["on"] + var on string + if !found || json.Unmarshal(raw, &on) != nil { + return fmt.Errorf("%w: waiting on is required", ErrInvalidArgument) + } + if on != "BOARD_WAITING_ON_OPERATOR" && on != "BOARD_WAITING_ON_AGENT" && on != "BOARD_WAITING_ON_EXTERNAL" { + return fmt.Errorf("%w: waiting on must be a recognized BOARD_WAITING_ON value", ErrInvalidArgument) + } + return nil +} + +func validateWaitingBlocks(raw json.RawMessage) error { + if len(raw) == 0 { + return nil + } + var blocks []string + if err := json.Unmarshal(raw, &blocks); err != nil { + return fmt.Errorf("%w: waiting blocks must be strings", ErrInvalidArgument) + } + for _, ref := range blocks { + if err := validateBoardNoNUL("waiting block ref", ref, ErrInvalidArgument); err != nil { + return err + } + } + return nil +} + func mergeSubagents(existing, updates []byte) ([]byte, error) { current, err := validateSubagents(existing, false) if err != nil { @@ -474,7 +563,7 @@ func mergeSubagents(existing, updates []byte) ([]byte, error) { positions[id] = len(current) current = append(current, item) } - for len(current) > maxBoardSubagents { + for len(current) > MaxBoardSubagents { oldest := -1 oldestAt := int64(math.MaxInt64) for i, item := range current { @@ -485,7 +574,7 @@ func mergeSubagents(existing, updates []byte) ([]byte, error) { } } if oldest < 0 { - return nil, fmt.Errorf("%w: task has more than %d unsettled subagents", ErrInvalidArgument, maxBoardSubagents) + return nil, fmt.Errorf("%w: task has more than %d unsettled subagents", ErrInvalidArgument, MaxBoardSubagents) } current = append(current[:oldest], current[oldest+1:]...) } @@ -517,6 +606,9 @@ func validateSubagents(raw []byte, callerInput bool) ([]json.RawMessage, error) return nil, errors.New("subagent entry must be an object") } id, err := subagentID(entry) + if err := validateSubagentStrings(value, callerInput); err != nil { + return nil, err + } if err != nil || id == "" { if callerInput { return nil, fmt.Errorf("%w: subagent id is required", ErrInvalidArgument) @@ -543,6 +635,55 @@ func subagentID(raw json.RawMessage) (string, error) { return value.ID, nil } +func validateSubagentStrings(value map[string]json.RawMessage, callerInput bool) error { + for _, field := range []struct { + name string + max int + }{{"id", MaxBoardSubagentIDChars}, {"agent", MaxBoardSubagentAgentChars}, {"description", MaxBoardSubagentDescriptionChars}, {"status", MaxBoardSubagentStatusChars}} { + raw, found := value[field.name] + if !found { + continue + } + var text string + if err := json.Unmarshal(raw, &text); err != nil { + if callerInput { + return fmt.Errorf("%w: subagent %s must be a string", ErrInvalidArgument, field.name) + } + return fmt.Errorf("subagent %s must be a string", field.name) + } + if callerInput { + if err := validateBoardString("subagent "+field.name, text, field.max, ErrInvalidArgument); err != nil { + return err + } + continue + } + if err := validateBoardNoNUL("subagent "+field.name, text, errors.New("invalid stored subagent")); err != nil { + return err + } + if utf8.RuneCountInString(text) > field.max { + return fmt.Errorf("subagent %s exceeds %d characters", field.name, field.max) + } + } + return nil +} + +func validateBoardString(field, value string, maxChars int, sentinel error) error { + if err := validateBoardNoNUL(field, value, sentinel); err != nil { + return err + } + if n := utf8.RuneCountInString(value); n > maxChars { + return fmt.Errorf("%w: %s has %d characters; maximum is %d", sentinel, field, n, maxChars) + } + return nil +} + +func validateBoardNoNUL(field, value string, sentinel error) error { + if strings.ContainsRune(value, '\x00') { + return fmt.Errorf("%w: %s contains a NUL character", sentinel, field) + } + return nil +} + func subagentSettled(raw json.RawMessage) (bool, int64) { var value struct { Status string `json:"status"` @@ -652,9 +793,8 @@ func persistBoardTasks(ctx context.Context, q *db.Queries, agent AccountID, next // AgentBoards returns stored board rows for the requested agents. func (s *Store) AgentBoards(ctx context.Context, agents []AccountID) (map[AccountID]BoardRow, error) { ids := uniqueAccountStrings(agents) - out := make(map[AccountID]BoardRow, len(ids)) if len(ids) == 0 { - return out, nil + return map[AccountID]BoardRow{}, nil } tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) if err != nil { @@ -664,13 +804,24 @@ func (s *Store) AgentBoards(ctx context.Context, agents []AccountID) (map[Accoun if err := armTx(ctx, s, tx); err != nil { return nil, err } - qtx := s.q.WithTx(tx) - boards, err := qtx.GetAgentBoards(ctx, ids) + rows, err := readAgentBoardRows(ctx, s.q.WithTx(tx), ids, s.afterBoardMetaRead) + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("store: commit board snapshot: %w", err) + } + return rows, nil +} + +func readAgentBoardRows(ctx context.Context, q *db.Queries, ids []string, afterMetaRead func()) (map[AccountID]BoardRow, error) { + out := make(map[AccountID]BoardRow, len(ids)) + boards, err := q.GetAgentBoards(ctx, ids) if err != nil { return nil, fmt.Errorf("store: read agent boards: %w", err) } - if s.afterBoardMetaRead != nil { - s.afterBoardMetaRead() + if afterMetaRead != nil { + afterMetaRead() } for _, row := range boards { id := AccountID(row.AgentAccountID) @@ -684,7 +835,7 @@ func (s *Store) AgentBoards(ctx context.Context, agents []AccountID) (map[Accoun Tasks: make([]BoardTaskRow, 0), } } - tasks, err := qtx.GetAgentBoardTasks(ctx, ids) + tasks, err := q.GetAgentBoardTasks(ctx, ids) if err != nil { return nil, fmt.Errorf("store: read agent board tasks: %w", err) } @@ -705,33 +856,68 @@ func (s *Store) AgentBoards(ctx context.Context, agents []AccountID) (map[Accoun }) out[id] = board } + return out, nil +} + +// SearchAndReadAgentBoards returns matching task keys and complete boards from one snapshot. +func (s *Store) SearchAndReadAgentBoards(ctx context.Context, f BoardTaskFilter) (map[AccountID][]string, map[AccountID]BoardRow, error) { + params := boardSearchParams(f) + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return nil, nil, fmt.Errorf("store: begin board search snapshot: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := armTx(ctx, s, tx); err != nil { + return nil, nil, err + } + qtx := s.q.WithTx(tx) + searchRows, err := qtx.SearchAgentBoardTasks(ctx, params) + if err != nil { + return nil, nil, fmt.Errorf("store: search agent boards: %w", err) + } + matches := make(map[AccountID][]string) + matchedIDs := make([]string, 0, len(searchRows)) + seen := make(map[string]struct{}, len(searchRows)) + for _, row := range searchRows { + id := AccountID(row.AgentAccountID) + if _, found := matches[id]; !found { + matches[id] = nil + } + if row.TaskKey != "" { + matches[id] = append(matches[id], row.TaskKey) + } + if _, found := seen[row.AgentAccountID]; !found { + seen[row.AgentAccountID] = struct{}{} + matchedIDs = append(matchedIDs, row.AgentAccountID) + } + } + if len(matchedIDs) == 0 { + if err := tx.Commit(ctx); err != nil { + return nil, nil, fmt.Errorf("store: commit board search snapshot: %w", err) + } + return matches, map[AccountID]BoardRow{}, nil + } + rows, err := readAgentBoardRows(ctx, qtx, matchedIDs, s.afterBoardMetaRead) + if err != nil { + return nil, nil, err + } if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("store: commit board snapshot: %w", err) + return nil, nil, fmt.Errorf("store: commit board search snapshot: %w", err) } - return out, nil + return matches, rows, nil } -// SearchAgentBoards returns task keys matched by all supplied filters. -func (s *Store) SearchAgentBoards(ctx context.Context, f BoardTaskFilter) (map[AccountID][]string, error) { +func boardSearchParams(f BoardTaskFilter) db.SearchAgentBoardTasksParams { limit := f.Limit if limit <= 0 { limit = 20 - } else if limit > maxBoardSearchAgents { - limit = maxBoardSearchAgents + } else if limit > MaxBoardSearchAgents { + limit = MaxBoardSearchAgents } - rows, err := s.q.SearchAgentBoardTasks(ctx, db.SearchAgentBoardTasksParams{ + return db.SearchAgentBoardTasksParams{ AgentAccountIds: uniqueAccountStrings(f.Agents), Query: strings.TrimSpace(f.Query), AgentLimit: int32(limit), Ref: f.Ref, WaitingOn: f.WaitingOn, State: f.State, Halt: f.Halt, - }) - if err != nil { - return nil, fmt.Errorf("store: search agent boards: %w", err) } - out := make(map[AccountID][]string) - for _, row := range rows { - id := AccountID(row.AgentAccountID) - out[id] = append(out[id], row.TaskKey) - } - return out, nil } func uniqueAccountStrings(agents []AccountID) []string { diff --git a/go/internal/store/agent_boards_pgtest_test.go b/go/internal/store/agent_boards_pgtest_test.go index e0a73bbdb..ac89da289 100644 --- a/go/internal/store/agent_boards_pgtest_test.go +++ b/go/internal/store/agent_boards_pgtest_test.go @@ -85,6 +85,7 @@ func TestApplyBoardPatchMergesFieldsAndDefaultsState(t *testing.T) { if got.WrittenTurn != 7 || got.WrittenAtUnixMs != 100 || got.Focus != "build" || got.Halt.Kind != 2 { t.Fatalf("initial board metadata = %+v", got) } + if len(got.Tasks) != 1 || got.Tasks[0].State != 2 || !reflect.DeepEqual(got.Tasks[0].Refs, refs) { t.Fatalf("initial task = %+v", got.Tasks) } @@ -223,7 +224,7 @@ func TestApplyBoardPatchReplaceTaskAtCapacity(t *testing.T) { s := newTestStore(t) owner := mustUser(t, s, "board-replace-full-owner") agent := mustAgent(t, s, owner.ID, "board-replace-full-agent") - upserts := make([]BoardTaskPatch, maxBoardTasks) + upserts := make([]BoardTaskPatch, MaxBoardTasks) for i := range upserts { upserts[i] = boardTask(fmt.Sprintf("task-%02d", i), "task") } @@ -241,7 +242,7 @@ func TestApplyBoardPatchReplaceTaskAtCapacity(t *testing.T) { t.Fatalf("AgentBoards after replacement: %v", err) } got := boards[agent.ID].Tasks - if len(got) != maxBoardTasks || got[0].Key != "task-01" || got[len(got)-1].Key != "replacement" { + if len(got) != MaxBoardTasks || got[0].Key != "task-01" || got[len(got)-1].Key != "replacement" { t.Fatalf("replacement board has unexpected tasks: count=%d first=%q last=%q", len(got), got[0].Key, got[len(got)-1].Key) } for i, task := range got { @@ -266,7 +267,7 @@ func TestApplyBoardPatchCapBreachPreservesExistingBoard(t *testing.T) { if err != nil { t.Fatalf("ApplyBoardPatch(initial): %v", err) } - tooMany := make([]BoardTaskPatch, maxBoardTasks) + tooMany := make([]BoardTaskPatch, MaxBoardTasks) for i := range tooMany { tooMany[i] = boardTask(fmt.Sprintf("extra-%02d", i), "extra") } @@ -463,7 +464,7 @@ func TestSearchAgentBoardsCombinesFilters(t *testing.T) { t.Fatalf("ApplyBoardPatch(%s): %v", item.key, err) } } - got, err := s.SearchAgentBoards(t.Context(), BoardTaskFilter{ + got, _, err := s.SearchAndReadAgentBoards(t.Context(), BoardTaskFilter{ Agents: []AccountID{agent.ID, other.ID}, Query: "migration", Ref: "RIG-12", WaitingOn: 1, State: 2, Halt: 2, }) if err != nil { @@ -474,6 +475,33 @@ func TestSearchAgentBoardsCombinesFilters(t *testing.T) { } } +func TestSearchAgentBoardsIncludesEmptyBoardsAndFiltersByBoardHalt(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "board-empty-search-owner") + agent := mustAgent(t, s, owner.ID, "board-empty-search-agent") + if _, err := s.ApplyBoardPatch(t.Context(), agent.ID, BoardPatch{ + Halt: &BoardHalt{Kind: 2}, NowUnixMs: 1, + }); err != nil { + t.Fatalf("ApplyBoardPatch(empty halted board): %v", err) + } + + matches, _, err := s.SearchAndReadAgentBoards(t.Context(), BoardTaskFilter{Agents: []AccountID{agent.ID}, Halt: 2}) + if err != nil { + t.Fatalf("SearchAgentBoards(blocked): %v", err) + } + if keys, ok := matches[agent.ID]; !ok || len(keys) != 0 { + t.Fatalf("SearchAgentBoards(blocked) = %v, want empty board with no matched tasks", matches) + } + + matches, _, err = s.SearchAndReadAgentBoards(t.Context(), BoardTaskFilter{Agents: []AccountID{agent.ID}, Halt: 1}) + if err != nil { + t.Fatalf("SearchAgentBoards(done): %v", err) + } + if _, ok := matches[agent.ID]; ok { + t.Fatalf("SearchAgentBoards(done) returned blocked board: %v", matches) + } +} + func TestSearchAgentBoardsTitleAndPrimaryRefOnly(t *testing.T) { s := newTestStore(t) owner := mustUser(t, s, "board-search-title-ref-owner") @@ -490,7 +518,7 @@ func TestSearchAgentBoardsTitleAndPrimaryRefOnly(t *testing.T) { t.Fatalf("ApplyBoardPatch: %v", err) } t.Run("title contains query", func(t *testing.T) { - got, err := s.SearchAgentBoards(t.Context(), BoardTaskFilter{Agents: []AccountID{agent.ID}, Query: "quartz"}) + got, _, err := s.SearchAndReadAgentBoards(t.Context(), BoardTaskFilter{Agents: []AccountID{agent.ID}, Query: "quartz"}) if err != nil { t.Fatalf("SearchAgentBoards(title query): %v", err) } @@ -499,7 +527,7 @@ func TestSearchAgentBoardsTitleAndPrimaryRefOnly(t *testing.T) { } }) t.Run("primary ref matches without refs entry", func(t *testing.T) { - got, err := s.SearchAgentBoards(t.Context(), BoardTaskFilter{Agents: []AccountID{agent.ID}, Ref: primaryRef}) + got, _, err := s.SearchAndReadAgentBoards(t.Context(), BoardTaskFilter{Agents: []AccountID{agent.ID}, Ref: primaryRef}) if err != nil { t.Fatalf("SearchAgentBoards(primary ref): %v", err) } @@ -512,7 +540,7 @@ func TestSearchAgentBoardsTitleAndPrimaryRefOnly(t *testing.T) { func TestSearchAgentBoardsClampsLimit(t *testing.T) { s := newTestStore(t) owner := mustUser(t, s, "board-search-limit-owner") - agents := make([]AccountID, maxBoardSearchAgents+1) + agents := make([]AccountID, MaxBoardSearchAgents+1) taskKeys := []string{"task-a", "task-b", "task-c"} title := "searchable" for i := range agents { @@ -526,17 +554,17 @@ func TestSearchAgentBoardsClampsLimit(t *testing.T) { t.Fatalf("ApplyBoardPatch(%d): %v", i, err) } } - got, err := s.SearchAgentBoards(t.Context(), BoardTaskFilter{Agents: agents, Limit: maxBoardSearchAgents + 1}) + got, _, err := s.SearchAndReadAgentBoards(t.Context(), BoardTaskFilter{Agents: agents, Limit: MaxBoardSearchAgents + 1}) if err != nil { t.Fatalf("SearchAgentBoards: %v", err) } - if len(got) != maxBoardSearchAgents { - t.Fatalf("SearchAgentBoards returned %d agents, want cap %d", len(got), maxBoardSearchAgents) + if len(got) != MaxBoardSearchAgents { + t.Fatalf("SearchAgentBoards returned %d agents, want cap %d", len(got), MaxBoardSearchAgents) } wantStoreIDs := append([]AccountID(nil), agents...) slices.Sort(wantStoreIDs) - wantStoreIDs = wantStoreIDs[:maxBoardSearchAgents] - gotStoreIDs := make([]AccountID, 0, maxBoardSearchAgents) + wantStoreIDs = wantStoreIDs[:MaxBoardSearchAgents] + gotStoreIDs := make([]AccountID, 0, MaxBoardSearchAgents) for agentID, keys := range got { gotStoreIDs = append(gotStoreIDs, agentID) if !reflect.DeepEqual(keys, taskKeys) { @@ -566,7 +594,7 @@ func TestSearchAgentBoardsZeroLimitDefaultsToTwenty(t *testing.T) { t.Fatalf("ApplyBoardPatch(%d): %v", i, err) } } - got, err := s.SearchAgentBoards(t.Context(), BoardTaskFilter{Limit: 0}) + got, _, err := s.SearchAndReadAgentBoards(t.Context(), BoardTaskFilter{Limit: 0}) if err != nil { t.Fatalf("SearchAgentBoards(zero limit): %v", err) } @@ -627,6 +655,38 @@ func TestAgentBoardsUsesOneSnapshotAcrossConcurrentPatch(t *testing.T) { } } +func TestSearchAndReadAgentBoardsUsesOneSnapshotAcrossConcurrentPatch(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "board-search-snapshot-owner") + agent := mustAgent(t, s, owner.ID, "board-search-snapshot-agent") + if _, err := s.ApplyBoardPatch(t.Context(), agent.ID, BoardPatch{ + Upsert: []BoardTaskPatch{boardTask("old", "match old")}, Focus: new("old"), NowUnixMs: 1, + }); err != nil { + t.Fatalf("ApplyBoardPatch(initial): %v", err) + } + var patchErr error + s.afterBoardMetaRead = func() { + s.afterBoardMetaRead = nil + _, patchErr = s.ApplyBoardPatch(t.Context(), agent.ID, BoardPatch{ + Upsert: []BoardTaskPatch{boardTask("new", "match new")}, Focus: new("new"), NowUnixMs: 2, + }) + } + matches, boards, err := s.SearchAndReadAgentBoards(t.Context(), BoardTaskFilter{Agents: []AccountID{agent.ID}, Query: "match"}) + if err != nil { + t.Fatalf("SearchAndReadAgentBoards: %v", err) + } + if patchErr != nil { + t.Fatalf("ApplyBoardPatch(concurrent): %v", patchErr) + } + if !reflect.DeepEqual(matches[agent.ID], []string{"old"}) { + t.Fatalf("matched task keys = %v, want old snapshot", matches[agent.ID]) + } + board := boards[agent.ID] + if board.Focus != "old" || len(board.Tasks) != 1 || board.Tasks[0].Key != "old" { + t.Fatalf("board rows came from a different snapshot: %+v", board) + } +} + func TestBoardCoordinateReadsAndOpenAuthoredRefs(t *testing.T) { s := newTestStore(t) agent, owner := seedAgent(t, s, "board-coords") @@ -844,7 +904,7 @@ func TestAgentBoardsTenantIsolation(t *testing.T) { if len(boards) != 0 { t.Fatalf("tenant B saw tenant A board: %+v", boards) } - matches, err := s.SearchAgentBoards(WithTenant(t.Context(), tenantB), BoardTaskFilter{Agents: []AccountID{agent.ID}}) + matches, _, err := s.SearchAndReadAgentBoards(WithTenant(t.Context(), tenantB), BoardTaskFilter{Agents: []AccountID{agent.ID}}) if err != nil { t.Fatalf("SearchAgentBoards(tenant B): %v", err) } @@ -852,3 +912,69 @@ func TestAgentBoardsTenantIsolation(t *testing.T) { t.Fatalf("tenant B saw tenant A task matches: %+v", matches) } } + +func TestApplyBoardPatchRejectsBoardFieldCharacterLimits(t *testing.T) { + s := newTestStore(t) + owner := mustUser(t, s, "board-field-limit-owner") + agent := mustAgent(t, s, owner.ID, "board-field-limit-agent") + title := "boundary task" + waiting := boardJSON(t, []map[string]any{{"on": "BOARD_WAITING_ON_OPERATOR", "agent_handle": strings.Repeat("h", 64)}}) + overlongWaiting := boardJSON(t, []map[string]any{{"on": "BOARD_WAITING_ON_OPERATOR", "agent_handle": strings.Repeat("h", 65)}}) + subagents := boardJSON(t, []map[string]any{{ + "id": strings.Repeat("i", 64), "agent": strings.Repeat("a", 64), + "description": strings.Repeat("d", 200), "status": strings.Repeat("s", 64), + }}) + if _, err := s.ApplyBoardPatch(t.Context(), agent.ID, BoardPatch{Upsert: []BoardTaskPatch{ + {Key: strings.Repeat("k", 64), Title: &title, Waiting: &waiting, UpsertSubagents: subagents}, + boardTask("probe", title), + }, NowUnixMs: 1}); err != nil { + t.Fatalf("ApplyBoardPatch(at size limits): %v", err) + } + badNULWaiting := boardJSON(t, []map[string]any{{"on": "BOARD_WAITING_ON_OPERATOR", "what": "bad\u0000text"}}) + badNULWaitingRef := boardJSON(t, []map[string]any{{"on": "BOARD_WAITING_ON_OPERATOR", "ref": "bad\u0000ref"}}) + badNULWaitingBlocks := boardJSON(t, []map[string]any{{"on": "BOARD_WAITING_ON_OPERATOR", "blocks": []string{"bad\u0000ref"}}}) + badNULSubagentID := boardJSON(t, []map[string]any{{"id": "bad\u0000id"}}) + badNULSubagentAgent := boardJSON(t, []map[string]any{{"id": "child-agent-nul", "agent": "bad\u0000agent"}}) + badNULSubagentDescription := boardJSON(t, []map[string]any{{"id": "child-description-nul", "description": "bad\u0000text"}}) + badNULSubagentStatus := boardJSON(t, []map[string]any{{"id": "child-status-nul", "status": "bad\u0000text"}}) + overlongSubagentStatus := boardJSON(t, []map[string]any{{"id": "child-status", "status": strings.Repeat("s", 65)}}) + badNULHaltReason := "bad\u0000text" + badNULHaltRef := "bad\u0000ref" + badNULFocus := "bad\u0000key" + badNULPrimaryRef := "bad\u0000ref" + badNULTaskTitle := "bad\u0000title" + badNULTaskNote := "bad\u0000note" + tests := []struct { + name string + patch BoardPatch + }{ + {name: "task key", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: strings.Repeat("k", 65), Title: &title}}}}, + {name: "waiting agent handle", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", Waiting: &overlongWaiting}}}}, + {name: "subagent id length", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", UpsertSubagents: boardJSON(t, []map[string]any{{"id": strings.Repeat("i", 65)}})}}}}, + {name: "subagent agent length", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", UpsertSubagents: boardJSON(t, []map[string]any{{"id": "child", "agent": strings.Repeat("a", 65)}})}}}}, + {name: "subagent description length", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", UpsertSubagents: boardJSON(t, []map[string]any{{"id": "child", "description": strings.Repeat("d", 201)}})}}}}, + {name: "subagent status length", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", UpsertSubagents: overlongSubagentStatus}}}}, + {name: "waiting NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", Waiting: &badNULWaiting}}}}, + {name: "subagent id NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", UpsertSubagents: badNULSubagentID}}}}, + {name: "subagent agent NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", UpsertSubagents: badNULSubagentAgent}}}}, + {name: "subagent description NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", UpsertSubagents: badNULSubagentDescription}}}}, + {name: "subagent status NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", UpsertSubagents: badNULSubagentStatus}}}}, + {name: "halt reason NUL", patch: BoardPatch{Halt: &BoardHalt{Kind: 1, Reason: badNULHaltReason}}}, + {name: "focus NUL", patch: BoardPatch{Focus: &badNULFocus}}, + {name: "task title NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", Title: &badNULTaskTitle}}}}, + {name: "task note NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", Title: &title, Note: &badNULTaskNote}}}}, + {name: "focus key NUL", patch: BoardPatch{Focus: &badNULFocus}}, + {name: "task refs NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", Title: &title, Refs: &[]string{"bad\u0000ref"}}}}}, + {name: "waiting ref NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", Waiting: &badNULWaitingRef}}}}, + {name: "waiting blocks NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", Waiting: &badNULWaitingBlocks}}}}, + {name: "halt ref NUL", patch: BoardPatch{Halt: &BoardHalt{Kind: 1, Ref: badNULHaltRef}}}, + {name: "primary ref NUL", patch: BoardPatch{Upsert: []BoardTaskPatch{{Key: "probe", Title: &title, PrimaryRef: &badNULPrimaryRef}}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.patch.NowUnixMs = 2 + _, err := s.ApplyBoardPatch(t.Context(), agent.ID, tc.patch) + sentinelIs(t, err, ErrInvalidArgument, "invalid "+tc.name) + }) + } +} diff --git a/go/internal/store/db/agent_boards.sql.go b/go/internal/store/db/agent_boards.sql.go index 4b1df7003..de45ceda0 100644 --- a/go/internal/store/db/agent_boards.sql.go +++ b/go/internal/store/db/agent_boards.sql.go @@ -559,31 +559,32 @@ const searchAgentBoardTasks = `-- name: SearchAgentBoardTasks :many WITH filtered_tasks AS ( SELECT t.agent_account_id, t.task_key, t.position FROM agent_board_tasks t - JOIN agent_boards b ON b.agent_account_id = t.agent_account_id WHERE (cardinality($1::text[]) = 0 OR t.agent_account_id = ANY($1::text[])) AND ($2::text = '' OR to_tsvector('english', t.title || ' ' || t.note) @@ websearch_to_tsquery('english', $2::text)) AND ($3::text = '' OR $3::text = ANY(t.refs) OR t.primary_ref = $3::text) AND ($4::smallint = 0 OR EXISTS ( - SELECT 1 FROM jsonb_array_elements(t.waiting) AS w(value) + SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(t.waiting) = 'array' THEN t.waiting ELSE '[]'::jsonb END) AS w(value) WHERE w.value->>'on' = CASE $4::smallint WHEN 1 THEN 'BOARD_WAITING_ON_OPERATOR' WHEN 2 THEN 'BOARD_WAITING_ON_AGENT' WHEN 3 THEN 'BOARD_WAITING_ON_EXTERNAL' ELSE '' END)) AND ($5::smallint = 0 OR t.state = $5::smallint) +), matching_agents AS ( + SELECT b.agent_account_id + FROM agent_boards b + WHERE (cardinality($1::text[]) = 0 OR b.agent_account_id = ANY($1::text[])) AND ($6::smallint = 0 OR b.halt_kind = $6::smallint) -), -matching_agents AS ( - SELECT DISTINCT agent_account_id - FROM filtered_tasks - ORDER BY agent_account_id + AND ($2::text = '' AND $3::text = '' AND $4::smallint = 0 AND $5::smallint = 0 + OR EXISTS (SELECT 1 FROM filtered_tasks f WHERE f.agent_account_id = b.agent_account_id)) + ORDER BY b.agent_account_id LIMIT $7 ) -SELECT f.agent_account_id, f.task_key - FROM filtered_tasks f - JOIN matching_agents a USING (agent_account_id) - ORDER BY f.agent_account_id, f.position, f.task_key +SELECT a.agent_account_id, COALESCE(f.task_key, '')::text AS task_key + FROM matching_agents a + LEFT JOIN filtered_tasks f USING (agent_account_id) + ORDER BY a.agent_account_id, f.position, f.task_key ` type SearchAgentBoardTasksParams struct { diff --git a/go/internal/store/queries/agent_boards.sql b/go/internal/store/queries/agent_boards.sql index 90b9416d6..953c36797 100644 --- a/go/internal/store/queries/agent_boards.sql +++ b/go/internal/store/queries/agent_boards.sql @@ -61,35 +61,37 @@ SELECT agent_account_id, task_key, title, primary_ref, refs, state, note, WHERE agent_account_id = ANY($1::text[]) ORDER BY position, task_key; + -- name: SearchAgentBoardTasks :many WITH filtered_tasks AS ( SELECT t.agent_account_id, t.task_key, t.position FROM agent_board_tasks t - JOIN agent_boards b ON b.agent_account_id = t.agent_account_id WHERE (cardinality(@agent_account_ids::text[]) = 0 OR t.agent_account_id = ANY(@agent_account_ids::text[])) AND (@query::text = '' OR to_tsvector('english', t.title || ' ' || t.note) @@ websearch_to_tsquery('english', @query::text)) AND (@ref::text = '' OR @ref::text = ANY(t.refs) OR t.primary_ref = @ref::text) AND (@waiting_on::smallint = 0 OR EXISTS ( - SELECT 1 FROM jsonb_array_elements(t.waiting) AS w(value) + SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(t.waiting) = 'array' THEN t.waiting ELSE '[]'::jsonb END) AS w(value) WHERE w.value->>'on' = CASE @waiting_on::smallint WHEN 1 THEN 'BOARD_WAITING_ON_OPERATOR' WHEN 2 THEN 'BOARD_WAITING_ON_AGENT' WHEN 3 THEN 'BOARD_WAITING_ON_EXTERNAL' ELSE '' END)) AND (@state::smallint = 0 OR t.state = @state::smallint) +), matching_agents AS ( + SELECT b.agent_account_id + FROM agent_boards b + WHERE (cardinality(@agent_account_ids::text[]) = 0 OR b.agent_account_id = ANY(@agent_account_ids::text[])) AND (@halt::smallint = 0 OR b.halt_kind = @halt::smallint) -), -matching_agents AS ( - SELECT DISTINCT agent_account_id - FROM filtered_tasks - ORDER BY agent_account_id + AND (@query::text = '' AND @ref::text = '' AND @waiting_on::smallint = 0 AND @state::smallint = 0 + OR EXISTS (SELECT 1 FROM filtered_tasks f WHERE f.agent_account_id = b.agent_account_id)) + ORDER BY b.agent_account_id LIMIT sqlc.arg(agent_limit) ) -SELECT f.agent_account_id, f.task_key - FROM filtered_tasks f - JOIN matching_agents a USING (agent_account_id) - ORDER BY f.agent_account_id, f.position, f.task_key; +SELECT a.agent_account_id, COALESCE(f.task_key, '')::text AS task_key + FROM matching_agents a + LEFT JOIN filtered_tasks f USING (agent_account_id) + ORDER BY a.agent_account_id, f.position, f.task_key; -- name: ListAsksByAuthor :many -- The EXISTS body is ChannelVisibleTo's predicate with the channel spelled diff --git a/go/server/serve.go b/go/server/serve.go index 4e2afccc5..d14ba211c 100644 --- a/go/server/serve.go +++ b/go/server/serve.go @@ -757,6 +757,11 @@ func seedBootstrapAccounts(ctx context.Context, st *store.Store, cfg ServeConfig return admin, system, linearBridge, nil } +// boardForges is the forge coordinate set board refs resolve against. +func boardForges(cfg ForgeConfig) board.Forges { + return board.Forges{GitHubHost: cfg.resolved().Host, LinearHost: forge.LinearHost} +} + // Serve binds the compass.v1 service to cfg.SocketPath and drives it until ctx // is cancelled. // @@ -881,6 +886,7 @@ func Serve(ctx context.Context, cfg ServeConfig) error { defer commsBus.Close() commsSvc := comms.NewComms(st, commsBus, fab, admin.ID) commsSvc.SetForgeHost(cfg.Forge.resolved().Host) + commsSvc.SetBoardForges(boardForges(cfg.Forge)) // Register the coordination-channel reconcile as the store's in-tx hook, so // the two parent-edge writers auto-provision/reconcile a manager's // coordination channel atomically with the tree edge (RIG-1722 T5). Wired here 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 72a523671..351d1b13f 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 @@ -34,7 +34,7 @@ import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; -import type { CreateChannelGroupRequest, CreateChannelGroupResponse, CreateChannelRequest, CreateChannelResponse, GetRosterRequest, GetRosterResponse, ListMessagesRequest, ListMessagesResponse, ListTopicsRequest, ListTopicsResponse, OpenDMRequest, OpenDMResponse, PostMessageRequest, PostMessageResponse, UpdateChannelMembersRequest, UpdateChannelMembersResponse, UpdatePinnedBoardRequest, UpdatePinnedBoardResponse } from "./comms_pb"; +import type { CreateChannelGroupRequest, CreateChannelGroupResponse, CreateChannelRequest, CreateChannelResponse, GetRosterRequest, GetRosterResponse, ListAgentBoardsRequest, ListAgentBoardsResponse, ListMessagesRequest, ListMessagesResponse, ListTopicsRequest, ListTopicsResponse, OpenDMRequest, OpenDMResponse, PostMessageRequest, PostMessageResponse, UpdateAgentBoardRequest, UpdateAgentBoardResponse, UpdateChannelMembersRequest, UpdateChannelMembersResponse, UpdatePinnedBoardRequest, UpdatePinnedBoardResponse } from "./comms_pb"; import { file_compass_v1_comms } from "./comms_pb"; import type { AgentControlSchema, AgentFrame } from "./agent_pb"; import { file_compass_v1_agent } from "./agent_pb"; @@ -48,7 +48,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file compass/v1/agent_gateway.proto. */ export const file_compass_v1_agent_gateway: GenFile = /*@__PURE__*/ - fileDesc("Ch5jb21wYXNzL3YxL2FnZW50X2dhdGV3YXkucHJvdG8SCmNvbXBhc3MudjEi8QQKEENvbW1zQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRIuCgRwb3N0GAIgASgLMh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3RIABIvCgRsaXN0GAMgASgLMh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0SAASLgoGcm9zdGVyGAQgASgLMhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0SAASNwoKc2V0X3N0YXR1cxgFIAEoCzIhLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXF1ZXN0SAASMwoDcGluGAYgASgLMiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3RIABI6Cg5jcmVhdGVfY2hhbm5lbBgHIAEoCzIgLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3RIABJBCg51cGRhdGVfbWVtYmVycxgIIAEoCzInLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0SAASRQoUY3JlYXRlX2NoYW5uZWxfZ3JvdXAYCSABKAsyJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3RIABIsCgdvcGVuX2RtGAsgASgLMhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0SAASNAoLbGlzdF90b3BpY3MYDCABKAsyHS5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXF1ZXN0SAASGwoTdHJpZ2dlcl90cmFjZXBhcmVudBgKIAEoCUIGCgRjYWxsIowFCg9Db21tc0NhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIvCgRwb3N0GAIgASgLMh8uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlc3BvbnNlSAASMAoEbGlzdBgDIAEoCzIgLmNvbXBhc3MudjEuTGlzdE1lc3NhZ2VzUmVzcG9uc2VIABIrCgVlcnJvchgEIAEoCzIaLmNvbXBhc3MudjEuQ29tbXNDYWxsRXJyb3JIABIvCgZyb3N0ZXIYBSABKAsyHS5jb21wYXNzLnYxLkdldFJvc3RlclJlc3BvbnNlSAASOAoKc2V0X3N0YXR1cxgGIAEoCzIiLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXNwb25zZUgAEjQKA3BpbhgHIAEoCzIlLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXNwb25zZUgAEjsKDmNyZWF0ZV9jaGFubmVsGAggASgLMiEuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsUmVzcG9uc2VIABJCCg51cGRhdGVfbWVtYmVycxgJIAEoCzIoLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXNwb25zZUgAEkYKFGNyZWF0ZV9jaGFubmVsX2dyb3VwGAogASgLMiYuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsR3JvdXBSZXNwb25zZUgAEi0KB29wZW5fZG0YCyABKAsyGi5jb21wYXNzLnYxLk9wZW5ETVJlc3BvbnNlSAASNQoLbGlzdF90b3BpY3MYDCABKAsyHi5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXNwb25zZUgAQggKBnJlc3VsdCIvCg5Db21tc0NhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiKQoVU2V0QWdlbnRTdGF0dXNSZXF1ZXN0EhAKCGFjdGl2aXR5GAEgASgJIhgKFlNldEFnZW50U3RhdHVzUmVzcG9uc2UikQEKFExpZmVjeWNsZUNhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSLQoFc3Bhd24YAiABKAsyHC5jb21wYXNzLnYxLlNwYXduUGVlclJlcXVlc3RIABIxCgdkZXNwYXduGAMgASgLMh4uY29tcGFzcy52MS5EZXNwYXduUGVlclJlcXVlc3RIAEIGCgRjYWxsIogBChBTcGF3blBlZXJSZXF1ZXN0Eg4KBmhhbmRsZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSGQoRY2xpZW50X3JlcXVlc3RfaWQYBCABKAkSDAoEcm9sZRgFIAEoCRIPCgdwZXJzb25hGAYgASgJSgQIAxAEUg5pbml0aWFsX3Byb21wdCJyChFTcGF3blBlZXJSZXNwb25zZRIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEhYKDmNvbnRhaW5lcl9uYW1lGAIgASgJEhIKCnNlc3Npb25faWQYAyABKAkSFwoPZG1fY2hhbm5lbF9uYW1lGAQgASgJIioKEkRlc3Bhd25QZWVyUmVxdWVzdBIUCgxhZ2VudF9oYW5kbGUYASABKAkiFQoTRGVzcGF3blBlZXJSZXNwb25zZSLFAQoTTGlmZWN5Y2xlQ2FsbFJlc3VsdBIPCgdjYWxsX2lkGAEgASgJEi4KBXNwYXduGAIgASgLMh0uY29tcGFzcy52MS5TcGF3blBlZXJSZXNwb25zZUgAEjIKB2Rlc3Bhd24YAyABKAsyHy5jb21wYXNzLnYxLkRlc3Bhd25QZWVyUmVzcG9uc2VIABIvCgVlcnJvchgEIAEoCzIeLmNvbXBhc3MudjEuTGlmZWN5Y2xlQ2FsbEVycm9ySABCCAoGcmVzdWx0IjMKEkxpZmVjeWNsZUNhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAki6wYKEEZvcmdlQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRI2CgxjcmVhdGVfaXNzdWUYAiABKAsyHi5jb21wYXNzLnYxLkNyZWF0ZUlzc3VlUmVxdWVzdEgAEj0KEGNvbW1lbnRfb25faXNzdWUYAyABKAsyIS5jb21wYXNzLnYxLkNvbW1lbnRPbklzc3VlUmVxdWVzdEgAEjAKCWdldF9pc3N1ZRgEIAEoCzIbLmNvbXBhc3MudjEuR2V0SXNzdWVSZXF1ZXN0SAASNAoLbGlzdF9pc3N1ZXMYBSABKAsyHS5jb21wYXNzLnYxLkxpc3RJc3N1ZXNSZXF1ZXN0SAASQwoTY3JlYXRlX3B1bGxfcmVxdWVzdBgGIAEoCzIkLmNvbXBhc3MudjEuQ3JlYXRlUHVsbFJlcXVlc3RSZXF1ZXN0SAASSgoXY29tbWVudF9vbl9wdWxsX3JlcXVlc3QYByABKAsyJy5jb21wYXNzLnYxLkNvbW1lbnRPblB1bGxSZXF1ZXN0UmVxdWVzdEgAEj0KEGdldF9wdWxsX3JlcXVlc3QYCCABKAsyIS5jb21wYXNzLnYxLkdldFB1bGxSZXF1ZXN0UmVxdWVzdEgAEjYKCXN1YnNjcmliZRgJIAEoCzIhLmNvbXBhc3MudjEuU3Vic2NyaWJlRm9yZ2VSZXF1ZXN0SAASOgoLdW5zdWJzY3JpYmUYCiABKAsyIy5jb21wYXNzLnYxLlVuc3Vic2NyaWJlRm9yZ2VSZXF1ZXN0SAASOAoNc3VibWl0X3JldmlldxgLIAEoCzIfLmNvbXBhc3MudjEuU3VibWl0UmV2aWV3UmVxdWVzdEgAEkkKFnRyYW5zaXRpb25faXNzdWVfc3RhdGUYDiABKAsyJy5jb21wYXNzLnYxLlRyYW5zaXRpb25Jc3N1ZVN0YXRlUmVxdWVzdEgAElYKHXRyYW5zaXRpb25fcHVsbF9yZXF1ZXN0X3N0YXRlGA8gASgLMi0uY29tcGFzcy52MS5UcmFuc2l0aW9uUHVsbFJlcXVlc3RTdGF0ZVJlcXVlc3RIABIjCgVmb3JnZRgMIAEoCzIULmNvbXBhc3MudjEuRm9yZ2VSZWYSGQoRY2xpZW50X3JlcXVlc3RfaWQYDSABKAlCBgoEY2FsbCLgAwoPRm9yZ2VDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSIgoFaXNzdWUYAiABKAsyES5jb21wYXNzLnYxLklzc3VlSAASLwoNaXNzdWVfY29tbWVudBgDIAEoCzIWLmNvbXBhc3MudjEuQ29tbWVudFJlZkgAEjAKBmlzc3VlcxgEIAEoCzIeLmNvbXBhc3MudjEuTGlzdElzc3Vlc1Jlc3BvbnNlSAASLwoMcHVsbF9yZXF1ZXN0GAUgASgLMhcuY29tcGFzcy52MS5QdWxsUmVxdWVzdEgAEiwKCnByX2NvbW1lbnQYBiABKAsyFi5jb21wYXNzLnYxLkNvbW1lbnRSZWZIABI4CgpzdWJzY3JpYmVkGAcgASgLMiIuY29tcGFzcy52MS5TdWJzY3JpYmVGb3JnZVJlc3BvbnNlSAASPAoMdW5zdWJzY3JpYmVkGAggASgLMiQuY29tcGFzcy52MS5VbnN1YnNjcmliZUZvcmdlUmVzcG9uc2VIABIrCgVlcnJvchgJIAEoCzIaLmNvbXBhc3MudjEuRm9yZ2VDYWxsRXJyb3JIABInCgZyZXZpZXcYCiABKAsyFS5jb21wYXNzLnYxLlJldmlld1JlZkgAQggKBnJlc3VsdCJHCg5Gb3JnZUNhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkSFgoOcmV0cnlfYWZ0ZXJfbXMYAyABKA0iTwoSQ3JlYXRlSXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFdGl0bGUYAiABKAkSDAoEYm9keRgDIAEoCRIOCgZsYWJlbHMYBCADKAkiSQoVQ29tbWVudE9uSXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSFAoMaXNzdWVfbnVtYmVyGAIgASgEEgwKBGJvZHkYAyABKAkiNQoPR2V0SXNzdWVSZXF1ZXN0EgwKBHJlcG8YASABKAkSFAoMaXNzdWVfbnVtYmVyGAIgASgEIk8KEUxpc3RJc3N1ZXNSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFc3RhdGUYAiABKAkSDgoGbGFiZWxzGAMgAygJEg0KBWxpbWl0GAQgASgNIjcKEkxpc3RJc3N1ZXNSZXNwb25zZRIhCgZpc3N1ZXMYASADKAsyES5jb21wYXNzLnYxLklzc3VlIqkBChhDcmVhdGVQdWxsUmVxdWVzdFJlcXVlc3QSDAoEcmVwbxgBIAEoCRINCgV0aXRsZRgCIAEoCRIMCgRib2R5GAMgASgJEhAKCGhlYWRfcmVmGAQgASgJEhAKCGJhc2VfcmVmGAUgASgJEg0KBWRyYWZ0GAYgASgIEi8KBWlzc3VlGAcgASgLMiAuY29tcGFzcy52MS5QdWxsUmVxdWVzdElzc3VlTGluayJZChRQdWxsUmVxdWVzdElzc3VlTGluaxIjCgVmb3JnZRgBIAEoCzIULmNvbXBhc3MudjEuRm9yZ2VSZWYSDAoEcmVwbxgCIAEoCRIOCgZudW1iZXIYAyABKAQiTAobQ29tbWVudE9uUHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSEQoJcHJfbnVtYmVyGAIgASgEEgwKBGJvZHkYAyABKAkiOAoVR2V0UHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSEQoJcHJfbnVtYmVyGAIgASgEIocBChNTdWJtaXRSZXZpZXdSZXF1ZXN0EgwKBHJlcG8YASABKAkSEQoJcHJfbnVtYmVyGAIgASgEEg8KB3ZlcmRpY3QYAyABKAkSDAoEYm9keRgEIAEoCRIwCghjb21tZW50cxgFIAMoCzIeLmNvbXBhc3MudjEuUmV2aWV3Q29tbWVudElucHV0IkwKElJldmlld0NvbW1lbnRJbnB1dBIMCgRwYXRoGAEgASgJEgwKBGxpbmUYAiABKA0SDAoEc2lkZRgDIAEoCRIMCgRib2R5GAQgASgJIn4KG1RyYW5zaXRpb25Jc3N1ZVN0YXRlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhQKDGlzc3VlX251bWJlchgCIAEoBBINCgVzdGF0ZRgDIAEoCRIUCgxjbG9zZV9yZWFzb24YBCABKAkSFgoOd29ya2Zsb3dfc3RhdGUYBSABKAkiUwohVHJhbnNpdGlvblB1bGxSZXF1ZXN0U3RhdGVSZXF1ZXN0EgwKBHJlcG8YASABKAkSEQoJcHJfbnVtYmVyGAIgASgEEg0KBXN0YXRlGAMgASgJIqYBChVTdWJzY3JpYmVGb3JnZVJlcXVlc3QSDAoEcmVwbxgBIAEoCRIrCgRraW5kGAIgASgOMh0uY29tcGFzcy52MS5Gb3JnZUFydGlmYWN0S2luZBIOCgZudW1iZXIYAyABKAQSMQoFc2NvcGUYBCABKA4yIi5jb21wYXNzLnYxLkZvcmdlU3Vic2NyaXB0aW9uU2NvcGUSDwoHcHJvamVjdBgFIAEoCSIxChZTdWJzY3JpYmVGb3JnZVJlc3BvbnNlEhcKD3N1YnNjcmlwdGlvbl9pZBgBIAEoCSIyChdVbnN1YnNjcmliZUZvcmdlUmVxdWVzdBIXCg9zdWJzY3JpcHRpb25faWQYASABKAkiGgoYVW5zdWJzY3JpYmVGb3JnZVJlc3BvbnNlImgKEEJvYXJkQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRI7Cg9zZXRfaXNzdWVfc3RhdGUYAiABKAsyIC5jb21wYXNzLnYxLlNldElzc3VlU3RhdGVSZXF1ZXN0SABCBgoEY2FsbCJPChRTZXRJc3N1ZVN0YXRlUmVxdWVzdBIQCghpc3N1ZV9pZBgBIAEoCRIlCgVzdGF0ZRgCIAEoDjIWLmNvbXBhc3MudjEuSXNzdWVTdGF0ZSI5ChVTZXRJc3N1ZVN0YXRlUmVzcG9uc2USIAoFaXNzdWUYASABKAsyES5jb21wYXNzLnYxLklzc3VlIpcBCg9Cb2FyZENhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRI8Cg9zZXRfaXNzdWVfc3RhdGUYAiABKAsyIS5jb21wYXNzLnYxLlNldElzc3VlU3RhdGVSZXNwb25zZUgAEisKBWVycm9yGAMgASgLMhouY29tcGFzcy52MS5Cb2FyZENhbGxFcnJvckgAQggKBnJlc3VsdCIvCg5Cb2FyZENhbGxFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiPAoTUHVibGlzaEZyYW1lUmVxdWVzdBIlCgVmcmFtZRgBIAEoCzIWLmNvbXBhc3MudjEuQWdlbnRGcmFtZSIWChRQdWJsaXNoRnJhbWVSZXNwb25zZSJeChxQb3N0Q29udmVyc2F0aW9uRnJhbWVSZXF1ZXN0EiUKBWZyYW1lGAEgASgLMhYuY29tcGFzcy52MS5BZ2VudEZyYW1lEhcKD2lkZW1wb3RlbmN5X2tleRgCIAEoCSIfCh1Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXNwb25zZSIZChdDb250cm9sU3Vic2NyaWJlUmVxdWVzdCI1ChVQdXRTZXNzaW9uQmxvYlJlcXVlc3QSDgoGc2hhMjU2GAEgASgJEgwKBGRhdGEYAiABKAwiGAoWUHV0U2Vzc2lvbkJsb2JSZXNwb25zZSqRAQoWRm9yZ2VTdWJzY3JpcHRpb25TY29wZRIoCiRGT1JHRV9TVUJTQ1JJUFRJT05fU0NPUEVfVU5TUEVDSUZJRUQQABIlCiFGT1JHRV9TVUJTQ1JJUFRJT05fU0NPUEVfQVJUSUZBQ1QQARImCiJGT1JHRV9TVUJTQ1JJUFRJT05fU0NPUEVfQ09OVEFJTkVSEAIyjQUKDEFnZW50R2F0ZXdheRJCCgVDb21tcxIcLmNvbXBhc3MudjEuQ29tbXNDYWxsUmVxdWVzdBobLmNvbXBhc3MudjEuQ29tbXNDYWxsUmVzdWx0Ek4KCUxpZmVjeWNsZRIgLmNvbXBhc3MudjEuTGlmZWN5Y2xlQ2FsbFJlcXVlc3QaHy5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxSZXN1bHQSTgoHUHVibGlzaBIfLmNvbXBhc3MudjEuUHVibGlzaEZyYW1lUmVxdWVzdBogLmNvbXBhc3MudjEuUHVibGlzaEZyYW1lUmVzcG9uc2UoARJsChVQb3N0Q29udmVyc2F0aW9uRnJhbWUSKC5jb21wYXNzLnYxLlBvc3RDb252ZXJzYXRpb25GcmFtZVJlcXVlc3QaKS5jb21wYXNzLnYxLlBvc3RDb252ZXJzYXRpb25GcmFtZVJlc3BvbnNlEkoKB0NvbnRyb2wSIy5jb21wYXNzLnYxLkNvbnRyb2xTdWJzY3JpYmVSZXF1ZXN0GhguY29tcGFzcy52MS5BZ2VudENvbnRyb2wwARJCCgVGb3JnZRIcLmNvbXBhc3MudjEuRm9yZ2VDYWxsUmVxdWVzdBobLmNvbXBhc3MudjEuRm9yZ2VDYWxsUmVzdWx0EkIKBUJvYXJkEhwuY29tcGFzcy52MS5Cb2FyZENhbGxSZXF1ZXN0GhsuY29tcGFzcy52MS5Cb2FyZENhbGxSZXN1bHQSVwoOUHV0U2Vzc2lvbkJsb2ISIS5jb21wYXNzLnYxLlB1dFNlc3Npb25CbG9iUmVxdWVzdBoiLmNvbXBhc3MudjEuUHV0U2Vzc2lvbkJsb2JSZXNwb25zZWIGcHJvdG8z", [file_compass_v1_comms, file_compass_v1_agent, file_compass_v1_compass, file_compass_v1_forge]); + fileDesc("Ch5jb21wYXNzL3YxL2FnZW50X2dhdGV3YXkucHJvdG8SCmNvbXBhc3MudjEi5AUKEENvbW1zQ2FsbFJlcXVlc3QSDwoHY2FsbF9pZBgBIAEoCRIuCgRwb3N0GAIgASgLMh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3RIABIvCgRsaXN0GAMgASgLMh8uY29tcGFzcy52MS5MaXN0TWVzc2FnZXNSZXF1ZXN0SAASLgoGcm9zdGVyGAQgASgLMhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0SAASNwoKc2V0X3N0YXR1cxgFIAEoCzIhLmNvbXBhc3MudjEuU2V0QWdlbnRTdGF0dXNSZXF1ZXN0SAASMwoDcGluGAYgASgLMiQuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlcXVlc3RIABI6Cg5jcmVhdGVfY2hhbm5lbBgHIAEoCzIgLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3RIABJBCg51cGRhdGVfbWVtYmVycxgIIAEoCzInLmNvbXBhc3MudjEuVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0SAASRQoUY3JlYXRlX2NoYW5uZWxfZ3JvdXAYCSABKAsyJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3RIABIsCgdvcGVuX2RtGAsgASgLMhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0SAASNAoLbGlzdF90b3BpY3MYDCABKAsyHS5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXF1ZXN0SAASOwoMdXBkYXRlX2JvYXJkGA0gASgLMiMuY29tcGFzcy52MS5VcGRhdGVBZ2VudEJvYXJkUmVxdWVzdEgAEjQKBmJvYXJkcxgOIAEoCzIiLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVxdWVzdEgAEhsKE3RyaWdnZXJfdHJhY2VwYXJlbnQYCiABKAlCBgoEY2FsbCKBBgoPQ29tbXNDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSLwoEcG9zdBgCIAEoCzIfLmNvbXBhc3MudjEuUG9zdE1lc3NhZ2VSZXNwb25zZUgAEjAKBGxpc3QYAyABKAsyIC5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1Jlc3BvbnNlSAASKwoFZXJyb3IYBCABKAsyGi5jb21wYXNzLnYxLkNvbW1zQ2FsbEVycm9ySAASLwoGcm9zdGVyGAUgASgLMh0uY29tcGFzcy52MS5HZXRSb3N0ZXJSZXNwb25zZUgAEjgKCnNldF9zdGF0dXMYBiABKAsyIi5jb21wYXNzLnYxLlNldEFnZW50U3RhdHVzUmVzcG9uc2VIABI0CgNwaW4YByABKAsyJS5jb21wYXNzLnYxLlVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2VIABI7Cg5jcmVhdGVfY2hhbm5lbBgIIAEoCzIhLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlSAASQgoOdXBkYXRlX21lbWJlcnMYCSABKAsyKC5jb21wYXNzLnYxLlVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2VIABJGChRjcmVhdGVfY2hhbm5lbF9ncm91cBgKIAEoCzImLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbEdyb3VwUmVzcG9uc2VIABItCgdvcGVuX2RtGAsgASgLMhouY29tcGFzcy52MS5PcGVuRE1SZXNwb25zZUgAEjUKC2xpc3RfdG9waWNzGAwgASgLMh4uY29tcGFzcy52MS5MaXN0VG9waWNzUmVzcG9uc2VIABI8Cgx1cGRhdGVfYm9hcmQYDSABKAsyJC5jb21wYXNzLnYxLlVwZGF0ZUFnZW50Qm9hcmRSZXNwb25zZUgAEjUKBmJvYXJkcxgOIAEoCzIjLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVzcG9uc2VIAEIICgZyZXN1bHQiLwoOQ29tbXNDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIikKFVNldEFnZW50U3RhdHVzUmVxdWVzdBIQCghhY3Rpdml0eRgBIAEoCSIYChZTZXRBZ2VudFN0YXR1c1Jlc3BvbnNlIpEBChRMaWZlY3ljbGVDYWxsUmVxdWVzdBIPCgdjYWxsX2lkGAEgASgJEi0KBXNwYXduGAIgASgLMhwuY29tcGFzcy52MS5TcGF3blBlZXJSZXF1ZXN0SAASMQoHZGVzcGF3bhgDIAEoCzIeLmNvbXBhc3MudjEuRGVzcGF3blBlZXJSZXF1ZXN0SABCBgoEY2FsbCKIAQoQU3Bhd25QZWVyUmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAQgASgJEgwKBHJvbGUYBSABKAkSDwoHcGVyc29uYRgGIAEoCUoECAMQBFIOaW5pdGlhbF9wcm9tcHQicgoRU3Bhd25QZWVyUmVzcG9uc2USGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIWCg5jb250YWluZXJfbmFtZRgCIAEoCRISCgpzZXNzaW9uX2lkGAMgASgJEhcKD2RtX2NoYW5uZWxfbmFtZRgEIAEoCSIqChJEZXNwYXduUGVlclJlcXVlc3QSFAoMYWdlbnRfaGFuZGxlGAEgASgJIhUKE0Rlc3Bhd25QZWVyUmVzcG9uc2UixQEKE0xpZmVjeWNsZUNhbGxSZXN1bHQSDwoHY2FsbF9pZBgBIAEoCRIuCgVzcGF3bhgCIAEoCzIdLmNvbXBhc3MudjEuU3Bhd25QZWVyUmVzcG9uc2VIABIyCgdkZXNwYXduGAMgASgLMh8uY29tcGFzcy52MS5EZXNwYXduUGVlclJlc3BvbnNlSAASLwoFZXJyb3IYBCABKAsyHi5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxFcnJvckgAQggKBnJlc3VsdCIzChJMaWZlY3ljbGVDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIusGChBGb3JnZUNhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSNgoMY3JlYXRlX2lzc3VlGAIgASgLMh4uY29tcGFzcy52MS5DcmVhdGVJc3N1ZVJlcXVlc3RIABI9ChBjb21tZW50X29uX2lzc3VlGAMgASgLMiEuY29tcGFzcy52MS5Db21tZW50T25Jc3N1ZVJlcXVlc3RIABIwCglnZXRfaXNzdWUYBCABKAsyGy5jb21wYXNzLnYxLkdldElzc3VlUmVxdWVzdEgAEjQKC2xpc3RfaXNzdWVzGAUgASgLMh0uY29tcGFzcy52MS5MaXN0SXNzdWVzUmVxdWVzdEgAEkMKE2NyZWF0ZV9wdWxsX3JlcXVlc3QYBiABKAsyJC5jb21wYXNzLnYxLkNyZWF0ZVB1bGxSZXF1ZXN0UmVxdWVzdEgAEkoKF2NvbW1lbnRfb25fcHVsbF9yZXF1ZXN0GAcgASgLMicuY29tcGFzcy52MS5Db21tZW50T25QdWxsUmVxdWVzdFJlcXVlc3RIABI9ChBnZXRfcHVsbF9yZXF1ZXN0GAggASgLMiEuY29tcGFzcy52MS5HZXRQdWxsUmVxdWVzdFJlcXVlc3RIABI2CglzdWJzY3JpYmUYCSABKAsyIS5jb21wYXNzLnYxLlN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjoKC3Vuc3Vic2NyaWJlGAogASgLMiMuY29tcGFzcy52MS5VbnN1YnNjcmliZUZvcmdlUmVxdWVzdEgAEjgKDXN1Ym1pdF9yZXZpZXcYCyABKAsyHy5jb21wYXNzLnYxLlN1Ym1pdFJldmlld1JlcXVlc3RIABJJChZ0cmFuc2l0aW9uX2lzc3VlX3N0YXRlGA4gASgLMicuY29tcGFzcy52MS5UcmFuc2l0aW9uSXNzdWVTdGF0ZVJlcXVlc3RIABJWCh10cmFuc2l0aW9uX3B1bGxfcmVxdWVzdF9zdGF0ZRgPIAEoCzItLmNvbXBhc3MudjEuVHJhbnNpdGlvblB1bGxSZXF1ZXN0U3RhdGVSZXF1ZXN0SAASIwoFZm9yZ2UYDCABKAsyFC5jb21wYXNzLnYxLkZvcmdlUmVmEhkKEWNsaWVudF9yZXF1ZXN0X2lkGA0gASgJQgYKBGNhbGwi4AMKD0ZvcmdlQ2FsbFJlc3VsdBIPCgdjYWxsX2lkGAEgASgJEiIKBWlzc3VlGAIgASgLMhEuY29tcGFzcy52MS5Jc3N1ZUgAEi8KDWlzc3VlX2NvbW1lbnQYAyABKAsyFi5jb21wYXNzLnYxLkNvbW1lbnRSZWZIABIwCgZpc3N1ZXMYBCABKAsyHi5jb21wYXNzLnYxLkxpc3RJc3N1ZXNSZXNwb25zZUgAEi8KDHB1bGxfcmVxdWVzdBgFIAEoCzIXLmNvbXBhc3MudjEuUHVsbFJlcXVlc3RIABIsCgpwcl9jb21tZW50GAYgASgLMhYuY29tcGFzcy52MS5Db21tZW50UmVmSAASOAoKc3Vic2NyaWJlZBgHIAEoCzIiLmNvbXBhc3MudjEuU3Vic2NyaWJlRm9yZ2VSZXNwb25zZUgAEjwKDHVuc3Vic2NyaWJlZBgIIAEoCzIkLmNvbXBhc3MudjEuVW5zdWJzY3JpYmVGb3JnZVJlc3BvbnNlSAASKwoFZXJyb3IYCSABKAsyGi5jb21wYXNzLnYxLkZvcmdlQ2FsbEVycm9ySAASJwoGcmV2aWV3GAogASgLMhUuY29tcGFzcy52MS5SZXZpZXdSZWZIAEIICgZyZXN1bHQiRwoORm9yZ2VDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJEhYKDnJldHJ5X2FmdGVyX21zGAMgASgNIk8KEkNyZWF0ZUlzc3VlUmVxdWVzdBIMCgRyZXBvGAEgASgJEg0KBXRpdGxlGAIgASgJEgwKBGJvZHkYAyABKAkSDgoGbGFiZWxzGAQgAygJIkkKFUNvbW1lbnRPbklzc3VlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhQKDGlzc3VlX251bWJlchgCIAEoBBIMCgRib2R5GAMgASgJIjUKD0dldElzc3VlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhQKDGlzc3VlX251bWJlchgCIAEoBCJPChFMaXN0SXNzdWVzUmVxdWVzdBIMCgRyZXBvGAEgASgJEg0KBXN0YXRlGAIgASgJEg4KBmxhYmVscxgDIAMoCRINCgVsaW1pdBgEIAEoDSI3ChJMaXN0SXNzdWVzUmVzcG9uc2USIQoGaXNzdWVzGAEgAygLMhEuY29tcGFzcy52MS5Jc3N1ZSKpAQoYQ3JlYXRlUHVsbFJlcXVlc3RSZXF1ZXN0EgwKBHJlcG8YASABKAkSDQoFdGl0bGUYAiABKAkSDAoEYm9keRgDIAEoCRIQCghoZWFkX3JlZhgEIAEoCRIQCghiYXNlX3JlZhgFIAEoCRINCgVkcmFmdBgGIAEoCBIvCgVpc3N1ZRgHIAEoCzIgLmNvbXBhc3MudjEuUHVsbFJlcXVlc3RJc3N1ZUxpbmsiWQoUUHVsbFJlcXVlc3RJc3N1ZUxpbmsSIwoFZm9yZ2UYASABKAsyFC5jb21wYXNzLnYxLkZvcmdlUmVmEgwKBHJlcG8YAiABKAkSDgoGbnVtYmVyGAMgASgEIkwKG0NvbW1lbnRPblB1bGxSZXF1ZXN0UmVxdWVzdBIMCgRyZXBvGAEgASgJEhEKCXByX251bWJlchgCIAEoBBIMCgRib2R5GAMgASgJIjgKFUdldFB1bGxSZXF1ZXN0UmVxdWVzdBIMCgRyZXBvGAEgASgJEhEKCXByX251bWJlchgCIAEoBCKHAQoTU3VibWl0UmV2aWV3UmVxdWVzdBIMCgRyZXBvGAEgASgJEhEKCXByX251bWJlchgCIAEoBBIPCgd2ZXJkaWN0GAMgASgJEgwKBGJvZHkYBCABKAkSMAoIY29tbWVudHMYBSADKAsyHi5jb21wYXNzLnYxLlJldmlld0NvbW1lbnRJbnB1dCJMChJSZXZpZXdDb21tZW50SW5wdXQSDAoEcGF0aBgBIAEoCRIMCgRsaW5lGAIgASgNEgwKBHNpZGUYAyABKAkSDAoEYm9keRgEIAEoCSJ+ChtUcmFuc2l0aW9uSXNzdWVTdGF0ZVJlcXVlc3QSDAoEcmVwbxgBIAEoCRIUCgxpc3N1ZV9udW1iZXIYAiABKAQSDQoFc3RhdGUYAyABKAkSFAoMY2xvc2VfcmVhc29uGAQgASgJEhYKDndvcmtmbG93X3N0YXRlGAUgASgJIlMKIVRyYW5zaXRpb25QdWxsUmVxdWVzdFN0YXRlUmVxdWVzdBIMCgRyZXBvGAEgASgJEhEKCXByX251bWJlchgCIAEoBBINCgVzdGF0ZRgDIAEoCSKmAQoVU3Vic2NyaWJlRm9yZ2VSZXF1ZXN0EgwKBHJlcG8YASABKAkSKwoEa2luZBgCIAEoDjIdLmNvbXBhc3MudjEuRm9yZ2VBcnRpZmFjdEtpbmQSDgoGbnVtYmVyGAMgASgEEjEKBXNjb3BlGAQgASgOMiIuY29tcGFzcy52MS5Gb3JnZVN1YnNjcmlwdGlvblNjb3BlEg8KB3Byb2plY3QYBSABKAkiMQoWU3Vic2NyaWJlRm9yZ2VSZXNwb25zZRIXCg9zdWJzY3JpcHRpb25faWQYASABKAkiMgoXVW5zdWJzY3JpYmVGb3JnZVJlcXVlc3QSFwoPc3Vic2NyaXB0aW9uX2lkGAEgASgJIhoKGFVuc3Vic2NyaWJlRm9yZ2VSZXNwb25zZSJoChBCb2FyZENhbGxSZXF1ZXN0Eg8KB2NhbGxfaWQYASABKAkSOwoPc2V0X2lzc3VlX3N0YXRlGAIgASgLMiAuY29tcGFzcy52MS5TZXRJc3N1ZVN0YXRlUmVxdWVzdEgAQgYKBGNhbGwiTwoUU2V0SXNzdWVTdGF0ZVJlcXVlc3QSEAoIaXNzdWVfaWQYASABKAkSJQoFc3RhdGUYAiABKA4yFi5jb21wYXNzLnYxLklzc3VlU3RhdGUiOQoVU2V0SXNzdWVTdGF0ZVJlc3BvbnNlEiAKBWlzc3VlGAEgASgLMhEuY29tcGFzcy52MS5Jc3N1ZSKXAQoPQm9hcmRDYWxsUmVzdWx0Eg8KB2NhbGxfaWQYASABKAkSPAoPc2V0X2lzc3VlX3N0YXRlGAIgASgLMiEuY29tcGFzcy52MS5TZXRJc3N1ZVN0YXRlUmVzcG9uc2VIABIrCgVlcnJvchgDIAEoCzIaLmNvbXBhc3MudjEuQm9hcmRDYWxsRXJyb3JIAEIICgZyZXN1bHQiLwoOQm9hcmRDYWxsRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjwKE1B1Ymxpc2hGcmFtZVJlcXVlc3QSJQoFZnJhbWUYASABKAsyFi5jb21wYXNzLnYxLkFnZW50RnJhbWUiFgoUUHVibGlzaEZyYW1lUmVzcG9uc2UiXgocUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVxdWVzdBIlCgVmcmFtZRgBIAEoCzIWLmNvbXBhc3MudjEuQWdlbnRGcmFtZRIXCg9pZGVtcG90ZW5jeV9rZXkYAiABKAkiHwodUG9zdENvbnZlcnNhdGlvbkZyYW1lUmVzcG9uc2UiGQoXQ29udHJvbFN1YnNjcmliZVJlcXVlc3QiNQoVUHV0U2Vzc2lvbkJsb2JSZXF1ZXN0Eg4KBnNoYTI1NhgBIAEoCRIMCgRkYXRhGAIgASgMIhgKFlB1dFNlc3Npb25CbG9iUmVzcG9uc2UqkQEKFkZvcmdlU3Vic2NyaXB0aW9uU2NvcGUSKAokRk9SR0VfU1VCU0NSSVBUSU9OX1NDT1BFX1VOU1BFQ0lGSUVEEAASJQohRk9SR0VfU1VCU0NSSVBUSU9OX1NDT1BFX0FSVElGQUNUEAESJgoiRk9SR0VfU1VCU0NSSVBUSU9OX1NDT1BFX0NPTlRBSU5FUhACMo0FCgxBZ2VudEdhdGV3YXkSQgoFQ29tbXMSHC5jb21wYXNzLnYxLkNvbW1zQ2FsbFJlcXVlc3QaGy5jb21wYXNzLnYxLkNvbW1zQ2FsbFJlc3VsdBJOCglMaWZlY3ljbGUSIC5jb21wYXNzLnYxLkxpZmVjeWNsZUNhbGxSZXF1ZXN0Gh8uY29tcGFzcy52MS5MaWZlY3ljbGVDYWxsUmVzdWx0Ek4KB1B1Ymxpc2gSHy5jb21wYXNzLnYxLlB1Ymxpc2hGcmFtZVJlcXVlc3QaIC5jb21wYXNzLnYxLlB1Ymxpc2hGcmFtZVJlc3BvbnNlKAESbAoVUG9zdENvbnZlcnNhdGlvbkZyYW1lEiguY29tcGFzcy52MS5Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXF1ZXN0GikuY29tcGFzcy52MS5Qb3N0Q29udmVyc2F0aW9uRnJhbWVSZXNwb25zZRJKCgdDb250cm9sEiMuY29tcGFzcy52MS5Db250cm9sU3Vic2NyaWJlUmVxdWVzdBoYLmNvbXBhc3MudjEuQWdlbnRDb250cm9sMAESQgoFRm9yZ2USHC5jb21wYXNzLnYxLkZvcmdlQ2FsbFJlcXVlc3QaGy5jb21wYXNzLnYxLkZvcmdlQ2FsbFJlc3VsdBJCCgVCb2FyZBIcLmNvbXBhc3MudjEuQm9hcmRDYWxsUmVxdWVzdBobLmNvbXBhc3MudjEuQm9hcmRDYWxsUmVzdWx0ElcKDlB1dFNlc3Npb25CbG9iEiEuY29tcGFzcy52MS5QdXRTZXNzaW9uQmxvYlJlcXVlc3QaIi5jb21wYXNzLnYxLlB1dFNlc3Npb25CbG9iUmVzcG9uc2ViBnByb3RvMw", [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 @@ -142,6 +142,18 @@ export type CommsCallRequest = Message<"compass.v1.CommsCallRequest"> & { */ value: ListTopicsRequest; case: "listTopics"; + } | { + /** + * @generated from field: compass.v1.UpdateAgentBoardRequest update_board = 13; + */ + value: UpdateAgentBoardRequest; + case: "updateBoard"; + } | { + /** + * @generated from field: compass.v1.ListAgentBoardsRequest boards = 14; + */ + value: ListAgentBoardsRequest; + case: "boards"; } | { case: undefined; value?: undefined }; /** @@ -172,8 +184,9 @@ export const CommsCallRequestSchema: GenMessage = /*@__PURE__* * The result of one comms call, correlated by `call_id`. A successful call sets * the response arm matching the request's operation (`post`, `list`, `roster`, * `set_status`, `pin`, `create_channel`, `update_members`, - * `create_channel_group`, `open_dm`, `list_topics`); an in-band failure (a tool error — non-member - * channel, bad input) sets `error`, which is NOT a transport teardown. The same + * `create_channel_group`, `open_dm`, `list_topics`, `update_board`, `boards`); + * an in-band failure (a tool error — non-member channel, bad input) sets + * `error`, which is NOT a transport teardown. The same * message is the `RelayCommsCallResponse.result` payload on the Runner->Server * leg. * @@ -254,6 +267,18 @@ export type CommsCallResult = Message<"compass.v1.CommsCallResult"> & { */ value: ListTopicsResponse; case: "listTopics"; + } | { + /** + * @generated from field: compass.v1.UpdateAgentBoardResponse update_board = 13; + */ + value: UpdateAgentBoardResponse; + case: "updateBoard"; + } | { + /** + * @generated from field: compass.v1.ListAgentBoardsResponse boards = 14; + */ + value: ListAgentBoardsResponse; + case: "boards"; } | { case: undefined; value?: undefined }; }; diff --git a/packages/compass-agent/src/gen/compass/v1/comms_pb.ts b/packages/compass-agent/src/gen/compass/v1/comms_pb.ts index 3b37acd23..0819bbe53 100644 --- a/packages/compass-agent/src/gen/compass/v1/comms_pb.ts +++ b/packages/compass-agent/src/gen/compass/v1/comms_pb.ts @@ -29,7 +29,7 @@ import type { Message as Message$1 } from "@bufbuild/protobuf"; * Describes the file compass/v1/comms.proto. */ export const file_compass_v1_comms: GenFile = /*@__PURE__*/ - fileDesc("ChZjb21wYXNzL3YxL2NvbW1zLnByb3RvEgpjb21wYXNzLnYxIsQBCgdBY2NvdW50EgoKAmlkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSJwoEdXNlchgKIAEoCzIXLmNvbXBhc3MudjEuVXNlckFjY291bnRIABIpCgVhZ2VudBgLIAEoCzIYLmNvbXBhc3MudjEuQWdlbnRBY2NvdW50SAASKwoGc3lzdGVtGAwgASgLMhkuY29tcGFzcy52MS5TeXN0ZW1BY2NvdW50SABCBgoEa2luZCIPCg1TeXN0ZW1BY2NvdW50IjEKC1VzZXJBY2NvdW50EiIKBHJvbGUYASABKA4yFC5jb21wYXNzLnYxLlVzZXJSb2xlIlcKDEFnZW50QWNjb3VudBIVCg1vd25lcl91c2VyX2lkGAEgASgJEhcKD2hvbWVfY2hhbm5lbF9pZBgCIAEoCRIXCg9wYXJlbnRfYWdlbnRfaWQYAyABKAkikAEKDENoYW5uZWxHcm91cBIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhcKD3BhcmVudF9ncm91cF9pZBgDIAEoCRIVCg1vd25lcl91c2VyX2lkGAQgASgJEjYKCnZpc2liaWxpdHkYBSABKA4yIi5jb21wYXNzLnYxLkNoYW5uZWxHcm91cFZpc2liaWxpdHkijAMKB0NoYW5uZWwSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIQCghncm91cF9pZBgDIAEoCRIlCgRraW5kGAQgASgOMhcuY29tcGFzcy52MS5DaGFubmVsS2luZBIaChJtZW1iZXJfYWNjb3VudF9pZHMYBSADKAkSHgoWc3Vic2NyaWJlcl9hY2NvdW50X2lkcxgGIAMoCRIyCgtwb3N0X3BvbGljeRgHIAEoDjIdLmNvbXBhc3MudjEuQ2hhbm5lbFBvc3RQb2xpY3kSGAoQb3duZXJfYWNjb3VudF9pZBgIIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAkgASgIEi8KDnBpbm5lZF9lbnRyaWVzGAogAygLMhcuY29tcGFzcy52MS5QaW5uZWRFbnRyeRIXCg9wYXJlbnRfYWdlbnRfaWQYCyABKAkSOgoPbWVtYmVyc2hpcF9tb2RlGAwgASgOMiEuY29tcGFzcy52MS5DaGFubmVsTWVtYmVyc2hpcE1vZGUibAoLUGlubmVkRW50cnkSEgoKbWVzc2FnZV9pZBgBIAEoCRIQCghwb3NpdGlvbhgCIAEoBRIZChFwaW5uZWRfYXRfdW5peF9tcxgDIAEoAxIcChRwaW5uZWRfYnlfYWNjb3VudF9pZBgEIAEoCSI2Cg5BZ2VudFdvcmtzcGFjZRIKCgJpZBgBIAEoCRIYChBhZ2VudF9hY2NvdW50X2lkGAIgASgJIvIBCgVUb3BpYxIKCgJpZBgBIAEoCRISCgpjaGFubmVsX2lkGAIgASgJEgwKBG5hbWUYAyABKAkSGgoSY3JlYXRlZF9hdF91bml4X21zGAQgASgDEh0KFWNyZWF0ZWRfYnlfYWNjb3VudF9pZBgFIAEoCRIQCghhcmNoaXZlZBgGIAEoCBIaCg1tZXNzYWdlX2NvdW50GAcgASgESACIAQESJAoXbGFzdF9tZXNzYWdlX2F0X3VuaXhfbXMYCCABKANIAYgBAUIQCg5fbWVzc2FnZV9jb3VudEIaChhfbGFzdF9tZXNzYWdlX2F0X3VuaXhfbXMiwgEKB01lc3NhZ2USCgoCaWQYASABKAkSEAoIdG9waWNfaWQYAiABKAkSGQoRYXV0aG9yX2FjY291bnRfaWQYAyABKAkSEgoKYXRfdW5peF9tcxgEIAEoAxIoCgZibG9ja3MYBSADKAsyGC5jb21wYXNzLnYxLk1lc3NhZ2VCbG9jaxIVCg1hdXRob3JfaGFuZGxlGAYgASgJEhIKCmNoYW5uZWxfaWQYByABKAkSFQoNdHVybl9zZXF1ZW5jZRgIIAEoBCJ5CgxNZXNzYWdlQmxvY2sSDgoEdGV4dBgBIAEoCUgAEh4KA2FzaxgCIAEoCzIPLmNvbXBhc3MudjEuQXNrSAASMAoKYXNrX2Fuc3dlchgDIAEoCzIaLmNvbXBhc3MudjEuQXNrQW5zd2VyQmxvY2tIAEIHCgVibG9jayJICg5Bc2tBbnN3ZXJCbG9jaxIcCgNhc2sYASABKAsyDy5jb21wYXNzLnYxLkFzaxIYChBhc2tlcl9hY2NvdW50X2lkGAIgASgJImAKA0FzaxIOCgZhc2tfaWQYASABKAkSKgoJcXVlc3Rpb25zGAIgAygLMhcuY29tcGFzcy52MS5Bc2tRdWVzdGlvbhIQCghhbnN3ZXJlZBgDIAEoCBILCgNyZWYYBCABKAki8QEKC0Fza1F1ZXN0aW9uEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhAKCHF1ZXN0aW9uGAIgASgJEg4KBmhlYWRlchgDIAEoCRImCgdvcHRpb25zGAQgAygLMhUuY29tcGFzcy52MS5Bc2tPcHRpb24SFgoOYWxsb3dfbXVsdGlwbGUYBSABKAgSGAoLcmVjb21tZW5kZWQYBiABKAVIAIgBARIZChFjaG9zZW5fb3B0aW9uX2lkcxgHIAMoCRITCgtjdXN0b21fdGV4dBgIIAEoCRIRCgl0aW1lZF9vdXQYCSABKAhCDgoMX3JlY29tbWVuZGVkIkwKCUFza09wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdwcmV2aWV3GAQgASgJIscFChZTdWJzY3JpYmVDb21tc1Jlc3BvbnNlEgsKA3NlcRgBIAEoBBISCgphdF91bml4X21zGAIgASgDEhYKDmluc3RhbmNlX2Vwb2NoGAMgASgEEhQKDHNuYXBzaG90X3NlcRgEIAEoBBIzCg5tZXNzYWdlX3Bvc3RlZBgKIAEoCzIZLmNvbXBhc3MudjEuTWVzc2FnZVBvc3RlZEgAEjUKD21lc3NhZ2VfdXBkYXRlZBgLIAEoCzIaLmNvbXBhc3MudjEuTWVzc2FnZVVwZGF0ZWRIABI1Cg9jaGFubmVsX2NoYW5nZWQYDCABKAsyGi5jb21wYXNzLnYxLkNoYW5uZWxDaGFuZ2VkSAASQAoVY2hhbm5lbF9ncm91cF9jaGFuZ2VkGA0gASgLMh8uY29tcGFzcy52MS5DaGFubmVsR3JvdXBDaGFuZ2VkSAASNQoPYWNjb3VudF9jaGFuZ2VkGA4gASgLMhouY29tcGFzcy52MS5BY2NvdW50Q2hhbmdlZEgAEkQKF2FnZW50X3dvcmtzcGFjZV9jaGFuZ2VkGA8gASgLMiEuY29tcGFzcy52MS5BZ2VudFdvcmtzcGFjZUNoYW5nZWRIABI6Cg9yZXN5bmNfcmVxdWlyZWQYECABKAsyHy5jb21wYXNzLnYxLkNvbW1zUmVzeW5jUmVxdWlyZWRIABJCChZhZ2VudF9wcmVzZW5jZV9jaGFuZ2VkGBEgASgLMiAuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlQ2hhbmdlZEgAEjMKDnRvcGljX3Vwc2VydGVkGBIgASgLMhkuY29tcGFzcy52MS5Ub3BpY1Vwc2VydGVkSAASPAoTYWdlbnRfYm9hcmRfY2hhbmdlZBgTIAEoCzIdLmNvbXBhc3MudjEuQWdlbnRCb2FyZENoYW5nZWRIAEIJCgdwYXlsb2FkIkwKDU1lc3NhZ2VQb3N0ZWQSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZRIVCg10dXJuX3NlcXVlbmNlGAIgASgEIjYKDk1lc3NhZ2VVcGRhdGVkEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiMQoNVG9waWNVcHNlcnRlZBIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiUwoOQ2hhbm5lbENoYW5nZWQSJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbBIbChNyZW1vdmVkX2FjY291bnRfaWRzGAIgAygJIj4KE0NoYW5uZWxHcm91cENoYW5nZWQSJwoFZ3JvdXAYASABKAsyGC5jb21wYXNzLnYxLkNoYW5uZWxHcm91cCI2Cg5BY2NvdW50Q2hhbmdlZBIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50IkYKFUFnZW50V29ya3NwYWNlQ2hhbmdlZBItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIhUKE0NvbW1zUmVzeW5jUmVxdWlyZWQibwoUQWdlbnRQcmVzZW5jZUNoYW5nZWQSGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIrCghwcmVzZW5jZRgCIAEoDjIZLmNvbXBhc3MudjEuQWdlbnRQcmVzZW5jZRIQCghhY3Rpdml0eRgDIAEoCSJ3CgxCb2FyZFdhaXRpbmcSJgoCb24YASABKA4yGi5jb21wYXNzLnYxLkJvYXJkV2FpdGluZ09uEhQKDGFnZW50X2hhbmRsZRgCIAEoCRIMCgR3aGF0GAMgASgJEgsKA3JlZhgEIAEoCRIOCgZibG9ja3MYBSADKAkihQEKDUJvYXJkU3ViYWdlbnQSCgoCaWQYASABKAkSDQoFYWdlbnQYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSDgoGc3RhdHVzGAQgASgJEhoKEnN0YXJ0ZWRfYXRfdW5peF9tcxgFIAEoAxIYChBlbmRlZF9hdF91bml4X21zGAYgASgDIvgBCglCb2FyZFRhc2sSCwoDa2V5GAEgASgJEg0KBXRpdGxlGAIgASgJEhMKC3ByaW1hcnlfcmVmGAMgASgJEgwKBHJlZnMYBCADKAkSKQoFc3RhdGUYBSABKA4yGi5jb21wYXNzLnYxLkJvYXJkVGFza1N0YXRlEgwKBG5vdGUYBiABKAkSKQoHd2FpdGluZxgHIAMoCzIYLmNvbXBhc3MudjEuQm9hcmRXYWl0aW5nEiwKCXN1YmFnZW50cxgIIAMoCzIZLmNvbXBhc3MudjEuQm9hcmRTdWJhZ2VudBIaChJ3cml0dGVuX2F0X3VuaXhfbXMYCSABKAMiHQoLQm9hcmRSZWZzT3ASDgoGdmFsdWVzGAEgAygJIjoKDkJvYXJkV2FpdGluZ09wEigKBnZhbHVlcxgBIAMoCzIYLmNvbXBhc3MudjEuQm9hcmRXYWl0aW5nIsQCCg5Cb2FyZFRhc2tQYXRjaBILCgNrZXkYASABKAkSEgoFdGl0bGUYAiABKAlIAIgBARIYCgtwcmltYXJ5X3JlZhgDIAEoCUgBiAEBEi4KBXN0YXRlGAQgASgOMhouY29tcGFzcy52MS5Cb2FyZFRhc2tTdGF0ZUgCiAEBEhEKBG5vdGUYBSABKAlIA4gBARIlCgRyZWZzGAYgASgLMhcuY29tcGFzcy52MS5Cb2FyZFJlZnNPcBIrCgd3YWl0aW5nGAcgASgLMhouY29tcGFzcy52MS5Cb2FyZFdhaXRpbmdPcBIzChB1cHNlcnRfc3ViYWdlbnRzGAggAygLMhkuY29tcGFzcy52MS5Cb2FyZFN1YmFnZW50QggKBl90aXRsZUIOCgxfcHJpbWFyeV9yZWZCCAoGX3N0YXRlQgcKBV9ub3RlIlEKCUJvYXJkSGFsdBInCgRraW5kGAEgASgOMhkuY29tcGFzcy52MS5Cb2FyZEhhbHRLaW5kEgsKA3JlZhgCIAEoCRIOCgZyZWFzb24YAyABKAkijwMKCEJvYXJkUmVmEgsKA3JlZhgBIAEoCRImCgRraW5kGAIgASgOMhguY29tcGFzcy52MS5Cb2FyZFJlZktpbmQSDQoFdGl0bGUYAyABKAkSCwoDdXJsGAQgASgJEhMKC2ZvcmdlX3N0YXRlGAUgASgJEisKC2lzc3VlX3N0YXRlGAYgASgOMhYuY29tcGFzcy52MS5Jc3N1ZVN0YXRlEg0KBWRyYWZ0GAcgASgIEhQKDGNoZWNrc19zdGF0ZRgIIAEoCRIlCgRnYXRlGAkgASgOMhcuY29tcGFzcy52MS5Cb2FyZFByR2F0ZRIVCg1zdGFja190aXBfcmVmGAogASgJEhYKDnN0YWNrX3Bvc2l0aW9uGAsgASgNEg0KBW93bmVkGAwgASgIEhsKE29ic2VydmVkX2F0X3VuaXhfbXMYDSABKAMSGQoRbWVyZ2VfcXVldWVfc3RhdGUYDiABKAkSFgoOc3RhbGVfYXBwcm92YWwYDyABKAgSFgoOdHJhY2tlcl9zdGF0dXMYECABKAki1AEKCEJvYXJkQXNrEg4KBmFza19pZBgBIAEoCRISCgptZXNzYWdlX2lkGAIgASgJEhIKCmNoYW5uZWxfaWQYAyABKAkSCwoDcmVmGAQgASgJEg4KBmhlYWRlchgFIAEoCRIQCghhbnN3ZXJlZBgGIAEoCBIZChFwb3N0ZWRfYXRfdW5peF9tcxgHIAEoAxIyCgxtaXJyb3Jfc3RhdGUYCCABKA4yHC5jb21wYXNzLnYxLkJvYXJkTWlycm9yU3RhdGUSEgoKbWlycm9yX3VybBgJIAEoCSJuChRNaXJyb3JUb0ZvcmdlUmVxdWVzdBILCgNyZWYYASABKAkSDgoGYXNrX2lkGAIgASgJEhAKCHRvcGljX2lkGAMgASgJEhMKC21lc3NhZ2VfaWRzGAQgAygJEhIKCmNoYW5uZWxfaWQYBSABKAkiLAoVTWlycm9yVG9Gb3JnZVJlc3BvbnNlEhMKC2NvbW1lbnRfdXJsGAEgASgJItUCCgpBZ2VudEJvYXJkEhgKEGFnZW50X2FjY291bnRfaWQYASABKAkSDgoGaGFuZGxlGAIgASgJEhYKDmZvY3VzX3Rhc2tfa2V5GAMgASgJEiMKBGhhbHQYBCABKAsyFS5jb21wYXNzLnYxLkJvYXJkSGFsdBIkCgV0YXNrcxgFIAMoCzIVLmNvbXBhc3MudjEuQm9hcmRUYXNrEiIKBHJlZnMYBiADKAsyFC5jb21wYXNzLnYxLkJvYXJkUmVmEhYKDnVudHJhY2tlZF9yZWZzGAcgAygJEiIKBGFza3MYCCADKAsyFC5jb21wYXNzLnYxLkJvYXJkQXNrEhQKDHdyaXR0ZW5fdHVybhgJIAEoBBIaChJ3cml0dGVuX2F0X3VuaXhfbXMYCiABKAMSDQoFc3RhbGUYCyABKAgSGQoRbWF0Y2hlZF90YXNrX2tleXMYDCADKAkiowEKEUFnZW50Qm9hcmRTdW1tYXJ5Eg0KBWZvY3VzGAEgASgJEhIKCnRhc2tfY291bnQYAiABKA0SGwoTd2FpdGluZ19vbl9vcGVyYXRvchgDIAEoDRIjCgRoYWx0GAQgASgLMhUuY29tcGFzcy52MS5Cb2FyZEhhbHQSGgoSd3JpdHRlbl9hdF91bml4X21zGAUgASgDEg0KBXN0YWxlGAYgASgIIvgBChdVcGRhdGVBZ2VudEJvYXJkUmVxdWVzdBIwCgx1cHNlcnRfdGFza3MYASADKAsyGi5jb21wYXNzLnYxLkJvYXJkVGFza1BhdGNoEhgKEHJlbW92ZV90YXNrX2tleXMYAiADKAkSGwoOZm9jdXNfdGFza19rZXkYAyABKAlIAIgBARIjCgRoYWx0GAQgASgLMhUuY29tcGFzcy52MS5Cb2FyZEhhbHQSEgoKY2xlYXJfaGFsdBgFIAEoCBIVCg10dXJuX3NlcXVlbmNlGAYgASgEEhEKCWF1dG9tYXRpYxgHIAEoCEIRCg9fZm9jdXNfdGFza19rZXkiQQoYVXBkYXRlQWdlbnRCb2FyZFJlc3BvbnNlEiUKBWJvYXJkGAEgASgLMhYuY29tcGFzcy52MS5BZ2VudEJvYXJkIt4BChZMaXN0QWdlbnRCb2FyZHNSZXF1ZXN0EhUKDWFnZW50X2hhbmRsZXMYASADKAkSDQoFcXVlcnkYAiABKAkSCwoDcmVmGAMgASgJEi4KCndhaXRpbmdfb24YBCABKA4yGi5jb21wYXNzLnYxLkJvYXJkV2FpdGluZ09uEikKBXN0YXRlGAUgASgOMhouY29tcGFzcy52MS5Cb2FyZFRhc2tTdGF0ZRInCgRoYWx0GAYgASgOMhkuY29tcGFzcy52MS5Cb2FyZEhhbHRLaW5kEg0KBWxpbWl0GAcgASgNIkEKF0xpc3RBZ2VudEJvYXJkc1Jlc3BvbnNlEiYKBmJvYXJkcxgBIAMoCzIWLmNvbXBhc3MudjEuQWdlbnRCb2FyZCJUChFBZ2VudEJvYXJkQ2hhbmdlZBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEiUKBWJvYXJkGAIgASgLMhYuY29tcGFzcy52MS5BZ2VudEJvYXJkIlsKB1BlZXJpbmcSFwoPdXNlcl9hY2NvdW50X2lkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRInCgVzdGF0ZRgDIAEoDjIYLmNvbXBhc3MudjEuUGVlcmluZ1N0YXRlIikKEkFwcHJvdmVQZWVyUmVxdWVzdBITCgtwZWVyX2hhbmRsZRgBIAEoCSI7ChNBcHByb3ZlUGVlclJlc3BvbnNlEiQKB3BlZXJpbmcYASABKAsyEy5jb21wYXNzLnYxLlBlZXJpbmciKAoRUmV2b2tlUGVlclJlcXVlc3QSEwoLcGVlcl9oYW5kbGUYASABKAkiJQoSUmV2b2tlUGVlclJlc3BvbnNlEg8KB2RlbGV0ZWQYASABKAgiEgoQTGlzdFBlZXJzUmVxdWVzdCI6ChFMaXN0UGVlcnNSZXNwb25zZRIlCghwZWVyaW5ncxgBIAMoCzITLmNvbXBhc3MudjEuUGVlcmluZyI5ChFDcmVhdGVVc2VyUmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJIjoKEkNyZWF0ZVVzZXJSZXNwb25zZRIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50InAKEkNyZWF0ZUFnZW50UmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhUKDXBhcmVudF9oYW5kbGUYAyABKAkSDAoEcm9sZRgEIAEoCRIPCgdwZXJzb25hGAUgASgJIjsKE0NyZWF0ZUFnZW50UmVzcG9uc2USJAoHYWNjb3VudBgBIAEoCzITLmNvbXBhc3MudjEuQWNjb3VudCIrChNMaXN0QWNjb3VudHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCI9ChRMaXN0QWNjb3VudHNSZXNwb25zZRIlCghhY2NvdW50cxgBIAMoCzITLmNvbXBhc3MudjEuQWNjb3VudCKVAQoZQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEhcKD3BhcmVudF9ncm91cF9pZBgCIAEoCRI2Cgp2aXNpYmlsaXR5GAMgASgOMiIuY29tcGFzcy52MS5DaGFubmVsR3JvdXBWaXNpYmlsaXR5EhkKEXBhcmVudF9ncm91cF9uYW1lGAQgASgJIkUKGkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEicKBWdyb3VwGAEgASgLMhguY29tcGFzcy52MS5DaGFubmVsR3JvdXAiMAoYTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCJFChlMaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlEigKBmdyb3VwcxgBIAMoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIisKE0xpc3RDaGFubmVsc1JlcXVlc3QSFAoMc25hcHNob3Rfc2VxGAEgASgEIj0KFExpc3RDaGFubmVsc1Jlc3BvbnNlEiUKCGNoYW5uZWxzGAEgAygLMhMuY29tcGFzcy52MS5DaGFubmVsIuIBChRDcmVhdGVDaGFubmVsUmVxdWVzdBIMCgRuYW1lGAEgASgJEhAKCGdyb3VwX2lkGAIgASgJEiUKBGtpbmQYAyABKA4yFy5jb21wYXNzLnYxLkNoYW5uZWxLaW5kEhYKDm1lbWJlcl9oYW5kbGVzGAQgAygJEhsKE3BhcmVudF9hZ2VudF9oYW5kbGUYBSABKAkSOgoPbWVtYmVyc2hpcF9tb2RlGAYgASgOMiEuY29tcGFzcy52MS5DaGFubmVsTWVtYmVyc2hpcE1vZGUSEgoKZ3JvdXBfbmFtZRgHIAEoCSI9ChVDcmVhdGVDaGFubmVsUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCLCAQobVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSGgoSYWRkX21lbWJlcl9oYW5kbGVzGAIgAygJEh0KFXJlbW92ZV9tZW1iZXJfaGFuZGxlcxgDIAMoCRIZChFzdWJzY3JpYmVfaGFuZGxlcxgEIAMoCRIbChN1bnN1YnNjcmliZV9oYW5kbGVzGAUgAygJEhwKFGNvbnZlcnRfY2hhbm5lbF9uYW1lGAYgASgJIkQKHFVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIkCg1PcGVuRE1SZXF1ZXN0EhMKC3BlZXJfaGFuZGxlGAEgASgJIkcKDk9wZW5ETVJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwSDwoHY3JlYXRlZBgCIAEoCCJHChRSZXBhcmVudEFnZW50UmVxdWVzdBIUCgxhZ2VudF9oYW5kbGUYASABKAkSGQoRbmV3X3BhcmVudF9oYW5kbGUYAiABKAkiPQoVUmVwYXJlbnRBZ2VudFJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiTQoWUmVwYXJlbnRDaGFubmVsUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEh8KF25ld19wYXJlbnRfYWdlbnRfaGFuZGxlGAIgASgJIj8KF1JlcGFyZW50Q2hhbm5lbFJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwilwEKF1NldENoYW5uZWxQb2xpY3lSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSMgoLcG9zdF9wb2xpY3kYAiABKA4yHS5jb21wYXNzLnYxLkNoYW5uZWxQb3N0UG9saWN5EhQKDG93bmVyX2hhbmRsZRgDIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAQgASgIIkAKGFNldENoYW5uZWxQb2xpY3lSZXNwb25zZRIkCgdjaGFubmVsGAEgASgLMhMuY29tcGFzcy52MS5DaGFubmVsIlIKEEdldFJvc3RlclJlcXVlc3QSJgoFc2NvcGUYASABKA4yFy5jb21wYXNzLnYxLlJvc3RlclNjb3BlEhYKDnZhbnRhZ2VfaGFuZGxlGAIgASgJIj0KEUdldFJvc3RlclJlc3BvbnNlEigKB2VudHJpZXMYASADKAsyFy5jb21wYXNzLnYxLlJvc3RlckVudHJ5IvABCgtSb3N0ZXJFbnRyeRIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSFwoPcGFyZW50X2FnZW50X2lkGAQgASgJEisKCHByZXNlbmNlGAUgASgOMhkuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlEhAKCGFjdGl2aXR5GAYgASgJEhsKE2FjdGl2aXR5X2F0X3VuaXhfbXMYByABKAMSLAoFYm9hcmQYCCABKAsyHS5jb21wYXNzLnYxLkFnZW50Qm9hcmRTdW1tYXJ5IncKGFVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEiUKA3BpbhgCIAEoCzIWLmNvbXBhc3MudjEuUGluTWVzc2FnZUgAEhoKEHVucGluX21lc3NhZ2VfaWQYAyABKAlIAEIECgJvcCI8CgpQaW5NZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSGgoScmVwbGFjZV9tZXNzYWdlX2lkGAIgASgJIkEKGVVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIxChlPcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSJLChpPcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIooBChNMaXN0TWVzc2FnZXNSZXF1ZXN0EhQKCmNoYW5uZWxfaWQYASABKAlIABINCgVsaW1pdBgCIAEoDRIZChFiZWZvcmVfbWVzc2FnZV9pZBgDIAEoCRIUCgxzbmFwc2hvdF9zZXEYBCABKAQSEAoIdG9waWNfaWQYBSABKAlCCwoJY29udGFpbmVyIj0KFExpc3RNZXNzYWdlc1Jlc3BvbnNlEiUKCG1lc3NhZ2VzGAEgAygLMhMuY29tcGFzcy52MS5NZXNzYWdlItwBChJQb3N0TWVzc2FnZVJlcXVlc3QSFAoKY2hhbm5lbF9pZBgBIAEoCUgAEigKBmJsb2NrcxgCIAMoCzIYLmNvbXBhc3MudjEuTWVzc2FnZUJsb2NrEhIKCHRvcGljX2lkGAMgASgJSAESFAoKdG9waWNfbmFtZRgEIAEoCUgBEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAUgASgJEhQKDGNyZWF0ZV90b3BpYxgGIAEoCBIVCg10dXJuX3NlcXVlbmNlGAcgASgEQgsKCWNvbnRhaW5lckIHCgV0b3BpYyI7ChNQb3N0TWVzc2FnZVJlc3BvbnNlEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiQQoRTGlzdFRvcGljc1JlcXVlc3QSEgoKY2hhbm5lbF9pZBgBIAEoCRIYChBpbmNsdWRlX2FyY2hpdmVkGAIgASgIIjcKEkxpc3RUb3BpY3NSZXNwb25zZRIhCgZ0b3BpY3MYASADKAsyES5jb21wYXNzLnYxLlRvcGljImYKElVwZGF0ZVRvcGljUmVxdWVzdBIQCgh0b3BpY19pZBgBIAEoCRIRCgRuYW1lGAIgASgJSACIAQESFQoIYXJjaGl2ZWQYAyABKAhIAYgBAUIHCgVfbmFtZUILCglfYXJjaGl2ZWQiNwoTVXBkYXRlVG9waWNSZXNwb25zZRIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiVQoTUmVzcG9uZFRvQXNrUmVxdWVzdBIOCgZhc2tfaWQYASABKAkSLgoHYW5zd2VycxgCIAMoCzIdLmNvbXBhc3MudjEuQXNrUXVlc3Rpb25BbnN3ZXIiWAoRQXNrUXVlc3Rpb25BbnN3ZXISEwoLcXVlc3Rpb25faWQYASABKAkSGQoRY2hvc2VuX29wdGlvbl9pZHMYAiADKAkSEwoLY3VzdG9tX3RleHQYAyABKAkiFgoUUmVzcG9uZFRvQXNrUmVzcG9uc2UiagoVU2VhcmNoTWVzc2FnZXNSZXF1ZXN0Eg0KBXF1ZXJ5GAEgASgJEhQKCmNoYW5uZWxfaWQYAiABKAlIABINCgVsaW1pdBgDIAEoDRIUCgxzbmFwc2hvdF9zZXEYBCABKARCBwoFc2NvcGUiPwoWU2VhcmNoTWVzc2FnZXNSZXNwb25zZRIlCghtZXNzYWdlcxgBIAMoCzITLmNvbXBhc3MudjEuTWVzc2FnZSJCChVTdWJzY3JpYmVDb21tc1JlcXVlc3QSEQoJc2luY2Vfc2VxGAEgASgEEhYKDmluc3RhbmNlX2Vwb2NoGAIgASgEKjUKCFVzZXJSb2xlEhQKEFVTRVJfUk9MRV9NRU1CRVIQABITCg9VU0VSX1JPTEVfQURNSU4QASphChZDaGFubmVsR3JvdXBWaXNpYmlsaXR5EiIKHkNIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9PV05FUhAAEiMKH0NIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9TSEFSRUQQASpVChFDaGFubmVsUG9zdFBvbGljeRIcChhDSEFOTkVMX1BPU1RfUE9MSUNZX09QRU4QABIiCh5DSEFOTkVMX1BPU1RfUE9MSUNZX09XTkVSX09OTFkQASpfChVDaGFubmVsTWVtYmVyc2hpcE1vZGUSJAogQ0hBTk5FTF9NRU1CRVJTSElQX01PREVfRVhQTElDSVQQABIgChxDSEFOTkVMX01FTUJFUlNISVBfTU9ERV9UUkVFEAEqWwoLQ2hhbm5lbEtpbmQSGAoUQ0hBTk5FTF9LSU5EX0NIQU5ORUwQABITCg9DSEFOTkVMX0tJTkRfRE0QARIdChVDSEFOTkVMX0tJTkRfR1JPVVBfRE0QAhoCCAEqnAEKDUFnZW50UHJlc2VuY2USHgoaQUdFTlRfUFJFU0VOQ0VfVU5TUEVDSUZJRUQQABIXChNBR0VOVF9QUkVTRU5DRV9JRExFEAESGgoWQUdFTlRfUFJFU0VOQ0VfV09SS0lORxACEhoKFkFHRU5UX1BSRVNFTkNFX1dBSVRJTkcQAxIaChZBR0VOVF9QUkVTRU5DRV9PRkZMSU5FEAQqwwEKDkJvYXJkVGFza1N0YXRlEiAKHEJPQVJEX1RBU0tfU1RBVEVfVU5TUEVDSUZJRUQQABIbChdCT0FSRF9UQVNLX1NUQVRFX0FDVElWRRABEhwKGEJPQVJEX1RBU0tfU1RBVEVfQkxPQ0tFRBACEhwKGEJPQVJEX1RBU0tfU1RBVEVfV0FJVElORxADEhsKF0JPQVJEX1RBU0tfU1RBVEVfUEFSS0VEEAQSGQoVQk9BUkRfVEFTS19TVEFURV9ET05FEAUqigEKDUJvYXJkSGFsdEtpbmQSHwobQk9BUkRfSEFMVF9LSU5EX1VOU1BFQ0lGSUVEEAASGAoUQk9BUkRfSEFMVF9LSU5EX0RPTkUQARIbChdCT0FSRF9IQUxUX0tJTkRfQkxPQ0tFRBACEiEKHUJPQVJEX0hBTFRfS0lORF9BV0FJVElOR19VU0VSEAMqjAEKDkJvYXJkV2FpdGluZ09uEiAKHEJPQVJEX1dBSVRJTkdfT05fVU5TUEVDSUZJRUQQABIdChlCT0FSRF9XQUlUSU5HX09OX09QRVJBVE9SEAESGgoWQk9BUkRfV0FJVElOR19PTl9BR0VOVBACEh0KGUJPQVJEX1dBSVRJTkdfT05fRVhURVJOQUwQAyppCgxCb2FyZFJlZktpbmQSHgoaQk9BUkRfUkVGX0tJTkRfVU5TUEVDSUZJRUQQABIYChRCT0FSRF9SRUZfS0lORF9JU1NVRRABEh8KG0JPQVJEX1JFRl9LSU5EX1BVTExfUkVRVUVTVBACKqADCgtCb2FyZFByR2F0ZRIdChlCT0FSRF9QUl9HQVRFX1VOU1BFQ0lGSUVEEAASFwoTQk9BUkRfUFJfR0FURV9EUkFGVBABEiAKHEJPQVJEX1BSX0dBVEVfQ0hFQ0tTX1BFTkRJTkcQAhIfChtCT0FSRF9QUl9HQVRFX0NIRUNLU19GQUlMRUQQAxIjCh9CT0FSRF9QUl9HQVRFX0NIQU5HRVNfUkVRVUVTVEVEEAQSIQodQk9BUkRfUFJfR0FURV9BV0FJVElOR19SRVZJRVcQBRIiCh5CT0FSRF9QUl9HQVRFX0FXQUlUSU5HX0VOUVVFVUUQBhIYChRCT0FSRF9QUl9HQVRFX01FUkdFRBAHEhgKFEJPQVJEX1BSX0dBVEVfQ0xPU0VEEAgSGAoUQk9BUkRfUFJfR0FURV9RVUVVRUQQCRIeChpCT0FSRF9QUl9HQVRFX1FVRVVFX0ZBSUxFRBAKEh8KG0JPQVJEX1BSX0dBVEVfUVVFVUVfUkVNT1ZFRBALEhsKF0JPQVJEX1BSX0dBVEVfQ09ORkxJQ1RTEAwqkgEKEEJvYXJkTWlycm9yU3RhdGUSIgoeQk9BUkRfTUlSUk9SX1NUQVRFX1VOU1BFQ0lGSUVEEAASHgoaQk9BUkRfTUlSUk9SX1NUQVRFX1BFTkRJTkcQARIbChdCT0FSRF9NSVJST1JfU1RBVEVfRE9ORRACEh0KGUJPQVJEX01JUlJPUl9TVEFURV9GQUlMRUQQAyqRAQoMUGVlcmluZ1N0YXRlEh0KGVBFRVJJTkdfU1RBVEVfVU5TUEVDSUZJRUQQABIiCh5QRUVSSU5HX1NUQVRFX1BFTkRJTkdfT1VUR09JTkcQARIiCh5QRUVSSU5HX1NUQVRFX1BFTkRJTkdfSU5DT01JTkcQAhIaChZQRUVSSU5HX1NUQVRFX0FQUFJPVkVEEAMqXgoLUm9zdGVyU2NvcGUSHQoZUk9TVEVSX1NDT1BFX05FSUdIQk9SSE9PRBAAEhgKFFJPU1RFUl9TQ09QRV9TVUJUUkVFEAESFgoSUk9TVEVSX1NDT1BFX09XTkVSEAIywBEKDENvbW1zU2VydmljZRJLCgpDcmVhdGVVc2VyEh0uY29tcGFzcy52MS5DcmVhdGVVc2VyUmVxdWVzdBoeLmNvbXBhc3MudjEuQ3JlYXRlVXNlclJlc3BvbnNlEk4KC0NyZWF0ZUFnZW50Eh4uY29tcGFzcy52MS5DcmVhdGVBZ2VudFJlcXVlc3QaHy5jb21wYXNzLnYxLkNyZWF0ZUFnZW50UmVzcG9uc2USUQoMTGlzdEFjY291bnRzEh8uY29tcGFzcy52MS5MaXN0QWNjb3VudHNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0QWNjb3VudHNSZXNwb25zZRJjChJDcmVhdGVDaGFubmVsR3JvdXASJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3QaJi5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEmAKEUxpc3RDaGFubmVsR3JvdXBzEiQuY29tcGFzcy52MS5MaXN0Q2hhbm5lbEdyb3Vwc1JlcXVlc3QaJS5jb21wYXNzLnYxLkxpc3RDaGFubmVsR3JvdXBzUmVzcG9uc2USUQoMTGlzdENoYW5uZWxzEh8uY29tcGFzcy52MS5MaXN0Q2hhbm5lbHNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0Q2hhbm5lbHNSZXNwb25zZRJUCg1DcmVhdGVDaGFubmVsEiAuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsUmVxdWVzdBohLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEmkKFFVwZGF0ZUNoYW5uZWxNZW1iZXJzEicuY29tcGFzcy52MS5VcGRhdGVDaGFubmVsTWVtYmVyc1JlcXVlc3QaKC5jb21wYXNzLnYxLlVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2USPwoGT3BlbkRNEhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0GhouY29tcGFzcy52MS5PcGVuRE1SZXNwb25zZRJUCg1SZXBhcmVudEFnZW50EiAuY29tcGFzcy52MS5SZXBhcmVudEFnZW50UmVxdWVzdBohLmNvbXBhc3MudjEuUmVwYXJlbnRBZ2VudFJlc3BvbnNlEloKD1JlcGFyZW50Q2hhbm5lbBIiLmNvbXBhc3MudjEuUmVwYXJlbnRDaGFubmVsUmVxdWVzdBojLmNvbXBhc3MudjEuUmVwYXJlbnRDaGFubmVsUmVzcG9uc2USYwoST3BlbkFnZW50V29ya3NwYWNlEiUuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0GiYuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRJRCgxMaXN0TWVzc2FnZXMSHy5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1Jlc3BvbnNlEk4KC1Bvc3RNZXNzYWdlEh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3QaHy5jb21wYXNzLnYxLlBvc3RNZXNzYWdlUmVzcG9uc2USSwoKTGlzdFRvcGljcxIdLmNvbXBhc3MudjEuTGlzdFRvcGljc1JlcXVlc3QaHi5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXNwb25zZRJOCgtVcGRhdGVUb3BpYxIeLmNvbXBhc3MudjEuVXBkYXRlVG9waWNSZXF1ZXN0Gh8uY29tcGFzcy52MS5VcGRhdGVUb3BpY1Jlc3BvbnNlElEKDFJlc3BvbmRUb0FzaxIfLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVxdWVzdBogLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVzcG9uc2USVAoNTWlycm9yVG9Gb3JnZRIgLmNvbXBhc3MudjEuTWlycm9yVG9Gb3JnZVJlcXVlc3QaIS5jb21wYXNzLnYxLk1pcnJvclRvRm9yZ2VSZXNwb25zZRJXCg5TZWFyY2hNZXNzYWdlcxIhLmNvbXBhc3MudjEuU2VhcmNoTWVzc2FnZXNSZXF1ZXN0GiIuY29tcGFzcy52MS5TZWFyY2hNZXNzYWdlc1Jlc3BvbnNlEl0KEFNldENoYW5uZWxQb2xpY3kSIy5jb21wYXNzLnYxLlNldENoYW5uZWxQb2xpY3lSZXF1ZXN0GiQuY29tcGFzcy52MS5TZXRDaGFubmVsUG9saWN5UmVzcG9uc2USSAoJR2V0Um9zdGVyEhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0Gh0uY29tcGFzcy52MS5HZXRSb3N0ZXJSZXNwb25zZRJgChFVcGRhdGVQaW5uZWRCb2FyZBIkLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXF1ZXN0GiUuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlc3BvbnNlEk4KC0FwcHJvdmVQZWVyEh4uY29tcGFzcy52MS5BcHByb3ZlUGVlclJlcXVlc3QaHy5jb21wYXNzLnYxLkFwcHJvdmVQZWVyUmVzcG9uc2USSwoKUmV2b2tlUGVlchIdLmNvbXBhc3MudjEuUmV2b2tlUGVlclJlcXVlc3QaHi5jb21wYXNzLnYxLlJldm9rZVBlZXJSZXNwb25zZRJICglMaXN0UGVlcnMSHC5jb21wYXNzLnYxLkxpc3RQZWVyc1JlcXVlc3QaHS5jb21wYXNzLnYxLkxpc3RQZWVyc1Jlc3BvbnNlElkKDlN1YnNjcmliZUNvbW1zEiEuY29tcGFzcy52MS5TdWJzY3JpYmVDb21tc1JlcXVlc3QaIi5jb21wYXNzLnYxLlN1YnNjcmliZUNvbW1zUmVzcG9uc2UwAWIGcHJvdG8z", [file_compass_v1_compass]); + fileDesc("ChZjb21wYXNzL3YxL2NvbW1zLnByb3RvEgpjb21wYXNzLnYxIsQBCgdBY2NvdW50EgoKAmlkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSJwoEdXNlchgKIAEoCzIXLmNvbXBhc3MudjEuVXNlckFjY291bnRIABIpCgVhZ2VudBgLIAEoCzIYLmNvbXBhc3MudjEuQWdlbnRBY2NvdW50SAASKwoGc3lzdGVtGAwgASgLMhkuY29tcGFzcy52MS5TeXN0ZW1BY2NvdW50SABCBgoEa2luZCIPCg1TeXN0ZW1BY2NvdW50IjEKC1VzZXJBY2NvdW50EiIKBHJvbGUYASABKA4yFC5jb21wYXNzLnYxLlVzZXJSb2xlIlcKDEFnZW50QWNjb3VudBIVCg1vd25lcl91c2VyX2lkGAEgASgJEhcKD2hvbWVfY2hhbm5lbF9pZBgCIAEoCRIXCg9wYXJlbnRfYWdlbnRfaWQYAyABKAkikAEKDENoYW5uZWxHcm91cBIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhcKD3BhcmVudF9ncm91cF9pZBgDIAEoCRIVCg1vd25lcl91c2VyX2lkGAQgASgJEjYKCnZpc2liaWxpdHkYBSABKA4yIi5jb21wYXNzLnYxLkNoYW5uZWxHcm91cFZpc2liaWxpdHkijAMKB0NoYW5uZWwSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIQCghncm91cF9pZBgDIAEoCRIlCgRraW5kGAQgASgOMhcuY29tcGFzcy52MS5DaGFubmVsS2luZBIaChJtZW1iZXJfYWNjb3VudF9pZHMYBSADKAkSHgoWc3Vic2NyaWJlcl9hY2NvdW50X2lkcxgGIAMoCRIyCgtwb3N0X3BvbGljeRgHIAEoDjIdLmNvbXBhc3MudjEuQ2hhbm5lbFBvc3RQb2xpY3kSGAoQb3duZXJfYWNjb3VudF9pZBgIIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAkgASgIEi8KDnBpbm5lZF9lbnRyaWVzGAogAygLMhcuY29tcGFzcy52MS5QaW5uZWRFbnRyeRIXCg9wYXJlbnRfYWdlbnRfaWQYCyABKAkSOgoPbWVtYmVyc2hpcF9tb2RlGAwgASgOMiEuY29tcGFzcy52MS5DaGFubmVsTWVtYmVyc2hpcE1vZGUibAoLUGlubmVkRW50cnkSEgoKbWVzc2FnZV9pZBgBIAEoCRIQCghwb3NpdGlvbhgCIAEoBRIZChFwaW5uZWRfYXRfdW5peF9tcxgDIAEoAxIcChRwaW5uZWRfYnlfYWNjb3VudF9pZBgEIAEoCSI2Cg5BZ2VudFdvcmtzcGFjZRIKCgJpZBgBIAEoCRIYChBhZ2VudF9hY2NvdW50X2lkGAIgASgJIvIBCgVUb3BpYxIKCgJpZBgBIAEoCRISCgpjaGFubmVsX2lkGAIgASgJEgwKBG5hbWUYAyABKAkSGgoSY3JlYXRlZF9hdF91bml4X21zGAQgASgDEh0KFWNyZWF0ZWRfYnlfYWNjb3VudF9pZBgFIAEoCRIQCghhcmNoaXZlZBgGIAEoCBIaCg1tZXNzYWdlX2NvdW50GAcgASgESACIAQESJAoXbGFzdF9tZXNzYWdlX2F0X3VuaXhfbXMYCCABKANIAYgBAUIQCg5fbWVzc2FnZV9jb3VudEIaChhfbGFzdF9tZXNzYWdlX2F0X3VuaXhfbXMiwgEKB01lc3NhZ2USCgoCaWQYASABKAkSEAoIdG9waWNfaWQYAiABKAkSGQoRYXV0aG9yX2FjY291bnRfaWQYAyABKAkSEgoKYXRfdW5peF9tcxgEIAEoAxIoCgZibG9ja3MYBSADKAsyGC5jb21wYXNzLnYxLk1lc3NhZ2VCbG9jaxIVCg1hdXRob3JfaGFuZGxlGAYgASgJEhIKCmNoYW5uZWxfaWQYByABKAkSFQoNdHVybl9zZXF1ZW5jZRgIIAEoBCJ5CgxNZXNzYWdlQmxvY2sSDgoEdGV4dBgBIAEoCUgAEh4KA2FzaxgCIAEoCzIPLmNvbXBhc3MudjEuQXNrSAASMAoKYXNrX2Fuc3dlchgDIAEoCzIaLmNvbXBhc3MudjEuQXNrQW5zd2VyQmxvY2tIAEIHCgVibG9jayJICg5Bc2tBbnN3ZXJCbG9jaxIcCgNhc2sYASABKAsyDy5jb21wYXNzLnYxLkFzaxIYChBhc2tlcl9hY2NvdW50X2lkGAIgASgJImAKA0FzaxIOCgZhc2tfaWQYASABKAkSKgoJcXVlc3Rpb25zGAIgAygLMhcuY29tcGFzcy52MS5Bc2tRdWVzdGlvbhIQCghhbnN3ZXJlZBgDIAEoCBILCgNyZWYYBCABKAki8QEKC0Fza1F1ZXN0aW9uEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhAKCHF1ZXN0aW9uGAIgASgJEg4KBmhlYWRlchgDIAEoCRImCgdvcHRpb25zGAQgAygLMhUuY29tcGFzcy52MS5Bc2tPcHRpb24SFgoOYWxsb3dfbXVsdGlwbGUYBSABKAgSGAoLcmVjb21tZW5kZWQYBiABKAVIAIgBARIZChFjaG9zZW5fb3B0aW9uX2lkcxgHIAMoCRITCgtjdXN0b21fdGV4dBgIIAEoCRIRCgl0aW1lZF9vdXQYCSABKAhCDgoMX3JlY29tbWVuZGVkIkwKCUFza09wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdwcmV2aWV3GAQgASgJIscFChZTdWJzY3JpYmVDb21tc1Jlc3BvbnNlEgsKA3NlcRgBIAEoBBISCgphdF91bml4X21zGAIgASgDEhYKDmluc3RhbmNlX2Vwb2NoGAMgASgEEhQKDHNuYXBzaG90X3NlcRgEIAEoBBIzCg5tZXNzYWdlX3Bvc3RlZBgKIAEoCzIZLmNvbXBhc3MudjEuTWVzc2FnZVBvc3RlZEgAEjUKD21lc3NhZ2VfdXBkYXRlZBgLIAEoCzIaLmNvbXBhc3MudjEuTWVzc2FnZVVwZGF0ZWRIABI1Cg9jaGFubmVsX2NoYW5nZWQYDCABKAsyGi5jb21wYXNzLnYxLkNoYW5uZWxDaGFuZ2VkSAASQAoVY2hhbm5lbF9ncm91cF9jaGFuZ2VkGA0gASgLMh8uY29tcGFzcy52MS5DaGFubmVsR3JvdXBDaGFuZ2VkSAASNQoPYWNjb3VudF9jaGFuZ2VkGA4gASgLMhouY29tcGFzcy52MS5BY2NvdW50Q2hhbmdlZEgAEkQKF2FnZW50X3dvcmtzcGFjZV9jaGFuZ2VkGA8gASgLMiEuY29tcGFzcy52MS5BZ2VudFdvcmtzcGFjZUNoYW5nZWRIABI6Cg9yZXN5bmNfcmVxdWlyZWQYECABKAsyHy5jb21wYXNzLnYxLkNvbW1zUmVzeW5jUmVxdWlyZWRIABJCChZhZ2VudF9wcmVzZW5jZV9jaGFuZ2VkGBEgASgLMiAuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlQ2hhbmdlZEgAEjMKDnRvcGljX3Vwc2VydGVkGBIgASgLMhkuY29tcGFzcy52MS5Ub3BpY1Vwc2VydGVkSAASPAoTYWdlbnRfYm9hcmRfY2hhbmdlZBgTIAEoCzIdLmNvbXBhc3MudjEuQWdlbnRCb2FyZENoYW5nZWRIAEIJCgdwYXlsb2FkIkwKDU1lc3NhZ2VQb3N0ZWQSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZRIVCg10dXJuX3NlcXVlbmNlGAIgASgEIjYKDk1lc3NhZ2VVcGRhdGVkEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiMQoNVG9waWNVcHNlcnRlZBIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiUwoOQ2hhbm5lbENoYW5nZWQSJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbBIbChNyZW1vdmVkX2FjY291bnRfaWRzGAIgAygJIj4KE0NoYW5uZWxHcm91cENoYW5nZWQSJwoFZ3JvdXAYASABKAsyGC5jb21wYXNzLnYxLkNoYW5uZWxHcm91cCI2Cg5BY2NvdW50Q2hhbmdlZBIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50IkYKFUFnZW50V29ya3NwYWNlQ2hhbmdlZBItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIhUKE0NvbW1zUmVzeW5jUmVxdWlyZWQibwoUQWdlbnRQcmVzZW5jZUNoYW5nZWQSGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIrCghwcmVzZW5jZRgCIAEoDjIZLmNvbXBhc3MudjEuQWdlbnRQcmVzZW5jZRIQCghhY3Rpdml0eRgDIAEoCSJ3CgxCb2FyZFdhaXRpbmcSJgoCb24YASABKA4yGi5jb21wYXNzLnYxLkJvYXJkV2FpdGluZ09uEhQKDGFnZW50X2hhbmRsZRgCIAEoCRIMCgR3aGF0GAMgASgJEgsKA3JlZhgEIAEoCRIOCgZibG9ja3MYBSADKAkihQEKDUJvYXJkU3ViYWdlbnQSCgoCaWQYASABKAkSDQoFYWdlbnQYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSDgoGc3RhdHVzGAQgASgJEhoKEnN0YXJ0ZWRfYXRfdW5peF9tcxgFIAEoAxIYChBlbmRlZF9hdF91bml4X21zGAYgASgDIvgBCglCb2FyZFRhc2sSCwoDa2V5GAEgASgJEg0KBXRpdGxlGAIgASgJEhMKC3ByaW1hcnlfcmVmGAMgASgJEgwKBHJlZnMYBCADKAkSKQoFc3RhdGUYBSABKA4yGi5jb21wYXNzLnYxLkJvYXJkVGFza1N0YXRlEgwKBG5vdGUYBiABKAkSKQoHd2FpdGluZxgHIAMoCzIYLmNvbXBhc3MudjEuQm9hcmRXYWl0aW5nEiwKCXN1YmFnZW50cxgIIAMoCzIZLmNvbXBhc3MudjEuQm9hcmRTdWJhZ2VudBIaChJ3cml0dGVuX2F0X3VuaXhfbXMYCSABKAMiHQoLQm9hcmRSZWZzT3ASDgoGdmFsdWVzGAEgAygJIjoKDkJvYXJkV2FpdGluZ09wEigKBnZhbHVlcxgBIAMoCzIYLmNvbXBhc3MudjEuQm9hcmRXYWl0aW5nIsQCCg5Cb2FyZFRhc2tQYXRjaBILCgNrZXkYASABKAkSEgoFdGl0bGUYAiABKAlIAIgBARIYCgtwcmltYXJ5X3JlZhgDIAEoCUgBiAEBEi4KBXN0YXRlGAQgASgOMhouY29tcGFzcy52MS5Cb2FyZFRhc2tTdGF0ZUgCiAEBEhEKBG5vdGUYBSABKAlIA4gBARIlCgRyZWZzGAYgASgLMhcuY29tcGFzcy52MS5Cb2FyZFJlZnNPcBIrCgd3YWl0aW5nGAcgASgLMhouY29tcGFzcy52MS5Cb2FyZFdhaXRpbmdPcBIzChB1cHNlcnRfc3ViYWdlbnRzGAggAygLMhkuY29tcGFzcy52MS5Cb2FyZFN1YmFnZW50QggKBl90aXRsZUIOCgxfcHJpbWFyeV9yZWZCCAoGX3N0YXRlQgcKBV9ub3RlIlEKCUJvYXJkSGFsdBInCgRraW5kGAEgASgOMhkuY29tcGFzcy52MS5Cb2FyZEhhbHRLaW5kEgsKA3JlZhgCIAEoCRIOCgZyZWFzb24YAyABKAkijwMKCEJvYXJkUmVmEgsKA3JlZhgBIAEoCRImCgRraW5kGAIgASgOMhguY29tcGFzcy52MS5Cb2FyZFJlZktpbmQSDQoFdGl0bGUYAyABKAkSCwoDdXJsGAQgASgJEhMKC2ZvcmdlX3N0YXRlGAUgASgJEisKC2lzc3VlX3N0YXRlGAYgASgOMhYuY29tcGFzcy52MS5Jc3N1ZVN0YXRlEg0KBWRyYWZ0GAcgASgIEhQKDGNoZWNrc19zdGF0ZRgIIAEoCRIlCgRnYXRlGAkgASgOMhcuY29tcGFzcy52MS5Cb2FyZFByR2F0ZRIVCg1zdGFja190aXBfcmVmGAogASgJEhYKDnN0YWNrX3Bvc2l0aW9uGAsgASgNEg0KBW93bmVkGAwgASgIEhsKE29ic2VydmVkX2F0X3VuaXhfbXMYDSABKAMSGQoRbWVyZ2VfcXVldWVfc3RhdGUYDiABKAkSFgoOc3RhbGVfYXBwcm92YWwYDyABKAgSFgoOdHJhY2tlcl9zdGF0dXMYECABKAki1AEKCEJvYXJkQXNrEg4KBmFza19pZBgBIAEoCRISCgptZXNzYWdlX2lkGAIgASgJEhIKCmNoYW5uZWxfaWQYAyABKAkSCwoDcmVmGAQgASgJEg4KBmhlYWRlchgFIAEoCRIQCghhbnN3ZXJlZBgGIAEoCBIZChFwb3N0ZWRfYXRfdW5peF9tcxgHIAEoAxIyCgxtaXJyb3Jfc3RhdGUYCCABKA4yHC5jb21wYXNzLnYxLkJvYXJkTWlycm9yU3RhdGUSEgoKbWlycm9yX3VybBgJIAEoCSJuChRNaXJyb3JUb0ZvcmdlUmVxdWVzdBILCgNyZWYYASABKAkSDgoGYXNrX2lkGAIgASgJEhAKCHRvcGljX2lkGAMgASgJEhMKC21lc3NhZ2VfaWRzGAQgAygJEhIKCmNoYW5uZWxfaWQYBSABKAkiLAoVTWlycm9yVG9Gb3JnZVJlc3BvbnNlEhMKC2NvbW1lbnRfdXJsGAEgASgJItUCCgpBZ2VudEJvYXJkEhgKEGFnZW50X2FjY291bnRfaWQYASABKAkSDgoGaGFuZGxlGAIgASgJEhYKDmZvY3VzX3Rhc2tfa2V5GAMgASgJEiMKBGhhbHQYBCABKAsyFS5jb21wYXNzLnYxLkJvYXJkSGFsdBIkCgV0YXNrcxgFIAMoCzIVLmNvbXBhc3MudjEuQm9hcmRUYXNrEiIKBHJlZnMYBiADKAsyFC5jb21wYXNzLnYxLkJvYXJkUmVmEhYKDnVudHJhY2tlZF9yZWZzGAcgAygJEiIKBGFza3MYCCADKAsyFC5jb21wYXNzLnYxLkJvYXJkQXNrEhQKDHdyaXR0ZW5fdHVybhgJIAEoBBIaChJ3cml0dGVuX2F0X3VuaXhfbXMYCiABKAMSDQoFc3RhbGUYCyABKAgSGQoRbWF0Y2hlZF90YXNrX2tleXMYDCADKAkiowEKEUFnZW50Qm9hcmRTdW1tYXJ5Eg0KBWZvY3VzGAEgASgJEhIKCnRhc2tfY291bnQYAiABKA0SGwoTd2FpdGluZ19vbl9vcGVyYXRvchgDIAEoDRIjCgRoYWx0GAQgASgLMhUuY29tcGFzcy52MS5Cb2FyZEhhbHQSGgoSd3JpdHRlbl9hdF91bml4X21zGAUgASgDEg0KBXN0YWxlGAYgASgIIvgBChdVcGRhdGVBZ2VudEJvYXJkUmVxdWVzdBIwCgx1cHNlcnRfdGFza3MYASADKAsyGi5jb21wYXNzLnYxLkJvYXJkVGFza1BhdGNoEhgKEHJlbW92ZV90YXNrX2tleXMYAiADKAkSGwoOZm9jdXNfdGFza19rZXkYAyABKAlIAIgBARIjCgRoYWx0GAQgASgLMhUuY29tcGFzcy52MS5Cb2FyZEhhbHQSEgoKY2xlYXJfaGFsdBgFIAEoCBIVCg10dXJuX3NlcXVlbmNlGAYgASgEEhEKCWF1dG9tYXRpYxgHIAEoCEIRCg9fZm9jdXNfdGFza19rZXkiQQoYVXBkYXRlQWdlbnRCb2FyZFJlc3BvbnNlEiUKBWJvYXJkGAEgASgLMhYuY29tcGFzcy52MS5BZ2VudEJvYXJkIt4BChZMaXN0QWdlbnRCb2FyZHNSZXF1ZXN0EhUKDWFnZW50X2hhbmRsZXMYASADKAkSDQoFcXVlcnkYAiABKAkSCwoDcmVmGAMgASgJEi4KCndhaXRpbmdfb24YBCABKA4yGi5jb21wYXNzLnYxLkJvYXJkV2FpdGluZ09uEikKBXN0YXRlGAUgASgOMhouY29tcGFzcy52MS5Cb2FyZFRhc2tTdGF0ZRInCgRoYWx0GAYgASgOMhkuY29tcGFzcy52MS5Cb2FyZEhhbHRLaW5kEg0KBWxpbWl0GAcgASgNIkEKF0xpc3RBZ2VudEJvYXJkc1Jlc3BvbnNlEiYKBmJvYXJkcxgBIAMoCzIWLmNvbXBhc3MudjEuQWdlbnRCb2FyZCJUChFBZ2VudEJvYXJkQ2hhbmdlZBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEiUKBWJvYXJkGAIgASgLMhYuY29tcGFzcy52MS5BZ2VudEJvYXJkIlsKB1BlZXJpbmcSFwoPdXNlcl9hY2NvdW50X2lkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRInCgVzdGF0ZRgDIAEoDjIYLmNvbXBhc3MudjEuUGVlcmluZ1N0YXRlIikKEkFwcHJvdmVQZWVyUmVxdWVzdBITCgtwZWVyX2hhbmRsZRgBIAEoCSI7ChNBcHByb3ZlUGVlclJlc3BvbnNlEiQKB3BlZXJpbmcYASABKAsyEy5jb21wYXNzLnYxLlBlZXJpbmciKAoRUmV2b2tlUGVlclJlcXVlc3QSEwoLcGVlcl9oYW5kbGUYASABKAkiJQoSUmV2b2tlUGVlclJlc3BvbnNlEg8KB2RlbGV0ZWQYASABKAgiEgoQTGlzdFBlZXJzUmVxdWVzdCI6ChFMaXN0UGVlcnNSZXNwb25zZRIlCghwZWVyaW5ncxgBIAMoCzITLmNvbXBhc3MudjEuUGVlcmluZyI5ChFDcmVhdGVVc2VyUmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJIjoKEkNyZWF0ZVVzZXJSZXNwb25zZRIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50InAKEkNyZWF0ZUFnZW50UmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhUKDXBhcmVudF9oYW5kbGUYAyABKAkSDAoEcm9sZRgEIAEoCRIPCgdwZXJzb25hGAUgASgJIjsKE0NyZWF0ZUFnZW50UmVzcG9uc2USJAoHYWNjb3VudBgBIAEoCzITLmNvbXBhc3MudjEuQWNjb3VudCIrChNMaXN0QWNjb3VudHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCI9ChRMaXN0QWNjb3VudHNSZXNwb25zZRIlCghhY2NvdW50cxgBIAMoCzITLmNvbXBhc3MudjEuQWNjb3VudCKVAQoZQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEhcKD3BhcmVudF9ncm91cF9pZBgCIAEoCRI2Cgp2aXNpYmlsaXR5GAMgASgOMiIuY29tcGFzcy52MS5DaGFubmVsR3JvdXBWaXNpYmlsaXR5EhkKEXBhcmVudF9ncm91cF9uYW1lGAQgASgJIkUKGkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEicKBWdyb3VwGAEgASgLMhguY29tcGFzcy52MS5DaGFubmVsR3JvdXAiMAoYTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCJFChlMaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlEigKBmdyb3VwcxgBIAMoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIisKE0xpc3RDaGFubmVsc1JlcXVlc3QSFAoMc25hcHNob3Rfc2VxGAEgASgEIj0KFExpc3RDaGFubmVsc1Jlc3BvbnNlEiUKCGNoYW5uZWxzGAEgAygLMhMuY29tcGFzcy52MS5DaGFubmVsIuIBChRDcmVhdGVDaGFubmVsUmVxdWVzdBIMCgRuYW1lGAEgASgJEhAKCGdyb3VwX2lkGAIgASgJEiUKBGtpbmQYAyABKA4yFy5jb21wYXNzLnYxLkNoYW5uZWxLaW5kEhYKDm1lbWJlcl9oYW5kbGVzGAQgAygJEhsKE3BhcmVudF9hZ2VudF9oYW5kbGUYBSABKAkSOgoPbWVtYmVyc2hpcF9tb2RlGAYgASgOMiEuY29tcGFzcy52MS5DaGFubmVsTWVtYmVyc2hpcE1vZGUSEgoKZ3JvdXBfbmFtZRgHIAEoCSI9ChVDcmVhdGVDaGFubmVsUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCLCAQobVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSGgoSYWRkX21lbWJlcl9oYW5kbGVzGAIgAygJEh0KFXJlbW92ZV9tZW1iZXJfaGFuZGxlcxgDIAMoCRIZChFzdWJzY3JpYmVfaGFuZGxlcxgEIAMoCRIbChN1bnN1YnNjcmliZV9oYW5kbGVzGAUgAygJEhwKFGNvbnZlcnRfY2hhbm5lbF9uYW1lGAYgASgJIkQKHFVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIkCg1PcGVuRE1SZXF1ZXN0EhMKC3BlZXJfaGFuZGxlGAEgASgJIkcKDk9wZW5ETVJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwSDwoHY3JlYXRlZBgCIAEoCCJHChRSZXBhcmVudEFnZW50UmVxdWVzdBIUCgxhZ2VudF9oYW5kbGUYASABKAkSGQoRbmV3X3BhcmVudF9oYW5kbGUYAiABKAkiPQoVUmVwYXJlbnRBZ2VudFJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiTQoWUmVwYXJlbnRDaGFubmVsUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEh8KF25ld19wYXJlbnRfYWdlbnRfaGFuZGxlGAIgASgJIj8KF1JlcGFyZW50Q2hhbm5lbFJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwilwEKF1NldENoYW5uZWxQb2xpY3lSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSMgoLcG9zdF9wb2xpY3kYAiABKA4yHS5jb21wYXNzLnYxLkNoYW5uZWxQb3N0UG9saWN5EhQKDG93bmVyX2hhbmRsZRgDIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAQgASgIIkAKGFNldENoYW5uZWxQb2xpY3lSZXNwb25zZRIkCgdjaGFubmVsGAEgASgLMhMuY29tcGFzcy52MS5DaGFubmVsIlIKEEdldFJvc3RlclJlcXVlc3QSJgoFc2NvcGUYASABKA4yFy5jb21wYXNzLnYxLlJvc3RlclNjb3BlEhYKDnZhbnRhZ2VfaGFuZGxlGAIgASgJIj0KEUdldFJvc3RlclJlc3BvbnNlEigKB2VudHJpZXMYASADKAsyFy5jb21wYXNzLnYxLlJvc3RlckVudHJ5IvABCgtSb3N0ZXJFbnRyeRIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSFwoPcGFyZW50X2FnZW50X2lkGAQgASgJEisKCHByZXNlbmNlGAUgASgOMhkuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlEhAKCGFjdGl2aXR5GAYgASgJEhsKE2FjdGl2aXR5X2F0X3VuaXhfbXMYByABKAMSLAoFYm9hcmQYCCABKAsyHS5jb21wYXNzLnYxLkFnZW50Qm9hcmRTdW1tYXJ5IncKGFVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEiUKA3BpbhgCIAEoCzIWLmNvbXBhc3MudjEuUGluTWVzc2FnZUgAEhoKEHVucGluX21lc3NhZ2VfaWQYAyABKAlIAEIECgJvcCI8CgpQaW5NZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSGgoScmVwbGFjZV9tZXNzYWdlX2lkGAIgASgJIkEKGVVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIxChlPcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSJLChpPcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIooBChNMaXN0TWVzc2FnZXNSZXF1ZXN0EhQKCmNoYW5uZWxfaWQYASABKAlIABINCgVsaW1pdBgCIAEoDRIZChFiZWZvcmVfbWVzc2FnZV9pZBgDIAEoCRIUCgxzbmFwc2hvdF9zZXEYBCABKAQSEAoIdG9waWNfaWQYBSABKAlCCwoJY29udGFpbmVyIj0KFExpc3RNZXNzYWdlc1Jlc3BvbnNlEiUKCG1lc3NhZ2VzGAEgAygLMhMuY29tcGFzcy52MS5NZXNzYWdlItwBChJQb3N0TWVzc2FnZVJlcXVlc3QSFAoKY2hhbm5lbF9pZBgBIAEoCUgAEigKBmJsb2NrcxgCIAMoCzIYLmNvbXBhc3MudjEuTWVzc2FnZUJsb2NrEhIKCHRvcGljX2lkGAMgASgJSAESFAoKdG9waWNfbmFtZRgEIAEoCUgBEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAUgASgJEhQKDGNyZWF0ZV90b3BpYxgGIAEoCBIVCg10dXJuX3NlcXVlbmNlGAcgASgEQgsKCWNvbnRhaW5lckIHCgV0b3BpYyI7ChNQb3N0TWVzc2FnZVJlc3BvbnNlEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiQQoRTGlzdFRvcGljc1JlcXVlc3QSEgoKY2hhbm5lbF9pZBgBIAEoCRIYChBpbmNsdWRlX2FyY2hpdmVkGAIgASgIIjcKEkxpc3RUb3BpY3NSZXNwb25zZRIhCgZ0b3BpY3MYASADKAsyES5jb21wYXNzLnYxLlRvcGljImYKElVwZGF0ZVRvcGljUmVxdWVzdBIQCgh0b3BpY19pZBgBIAEoCRIRCgRuYW1lGAIgASgJSACIAQESFQoIYXJjaGl2ZWQYAyABKAhIAYgBAUIHCgVfbmFtZUILCglfYXJjaGl2ZWQiNwoTVXBkYXRlVG9waWNSZXNwb25zZRIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiVQoTUmVzcG9uZFRvQXNrUmVxdWVzdBIOCgZhc2tfaWQYASABKAkSLgoHYW5zd2VycxgCIAMoCzIdLmNvbXBhc3MudjEuQXNrUXVlc3Rpb25BbnN3ZXIiWAoRQXNrUXVlc3Rpb25BbnN3ZXISEwoLcXVlc3Rpb25faWQYASABKAkSGQoRY2hvc2VuX29wdGlvbl9pZHMYAiADKAkSEwoLY3VzdG9tX3RleHQYAyABKAkiFgoUUmVzcG9uZFRvQXNrUmVzcG9uc2UiagoVU2VhcmNoTWVzc2FnZXNSZXF1ZXN0Eg0KBXF1ZXJ5GAEgASgJEhQKCmNoYW5uZWxfaWQYAiABKAlIABINCgVsaW1pdBgDIAEoDRIUCgxzbmFwc2hvdF9zZXEYBCABKARCBwoFc2NvcGUiPwoWU2VhcmNoTWVzc2FnZXNSZXNwb25zZRIlCghtZXNzYWdlcxgBIAMoCzITLmNvbXBhc3MudjEuTWVzc2FnZSJCChVTdWJzY3JpYmVDb21tc1JlcXVlc3QSEQoJc2luY2Vfc2VxGAEgASgEEhYKDmluc3RhbmNlX2Vwb2NoGAIgASgEKjUKCFVzZXJSb2xlEhQKEFVTRVJfUk9MRV9NRU1CRVIQABITCg9VU0VSX1JPTEVfQURNSU4QASphChZDaGFubmVsR3JvdXBWaXNpYmlsaXR5EiIKHkNIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9PV05FUhAAEiMKH0NIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9TSEFSRUQQASpVChFDaGFubmVsUG9zdFBvbGljeRIcChhDSEFOTkVMX1BPU1RfUE9MSUNZX09QRU4QABIiCh5DSEFOTkVMX1BPU1RfUE9MSUNZX09XTkVSX09OTFkQASpfChVDaGFubmVsTWVtYmVyc2hpcE1vZGUSJAogQ0hBTk5FTF9NRU1CRVJTSElQX01PREVfRVhQTElDSVQQABIgChxDSEFOTkVMX01FTUJFUlNISVBfTU9ERV9UUkVFEAEqWwoLQ2hhbm5lbEtpbmQSGAoUQ0hBTk5FTF9LSU5EX0NIQU5ORUwQABITCg9DSEFOTkVMX0tJTkRfRE0QARIdChVDSEFOTkVMX0tJTkRfR1JPVVBfRE0QAhoCCAEqnAEKDUFnZW50UHJlc2VuY2USHgoaQUdFTlRfUFJFU0VOQ0VfVU5TUEVDSUZJRUQQABIXChNBR0VOVF9QUkVTRU5DRV9JRExFEAESGgoWQUdFTlRfUFJFU0VOQ0VfV09SS0lORxACEhoKFkFHRU5UX1BSRVNFTkNFX1dBSVRJTkcQAxIaChZBR0VOVF9QUkVTRU5DRV9PRkZMSU5FEAQqwwEKDkJvYXJkVGFza1N0YXRlEiAKHEJPQVJEX1RBU0tfU1RBVEVfVU5TUEVDSUZJRUQQABIbChdCT0FSRF9UQVNLX1NUQVRFX0FDVElWRRABEhwKGEJPQVJEX1RBU0tfU1RBVEVfQkxPQ0tFRBACEhwKGEJPQVJEX1RBU0tfU1RBVEVfV0FJVElORxADEhsKF0JPQVJEX1RBU0tfU1RBVEVfUEFSS0VEEAQSGQoVQk9BUkRfVEFTS19TVEFURV9ET05FEAUqigEKDUJvYXJkSGFsdEtpbmQSHwobQk9BUkRfSEFMVF9LSU5EX1VOU1BFQ0lGSUVEEAASGAoUQk9BUkRfSEFMVF9LSU5EX0RPTkUQARIbChdCT0FSRF9IQUxUX0tJTkRfQkxPQ0tFRBACEiEKHUJPQVJEX0hBTFRfS0lORF9BV0FJVElOR19VU0VSEAMqjAEKDkJvYXJkV2FpdGluZ09uEiAKHEJPQVJEX1dBSVRJTkdfT05fVU5TUEVDSUZJRUQQABIdChlCT0FSRF9XQUlUSU5HX09OX09QRVJBVE9SEAESGgoWQk9BUkRfV0FJVElOR19PTl9BR0VOVBACEh0KGUJPQVJEX1dBSVRJTkdfT05fRVhURVJOQUwQAyppCgxCb2FyZFJlZktpbmQSHgoaQk9BUkRfUkVGX0tJTkRfVU5TUEVDSUZJRUQQABIYChRCT0FSRF9SRUZfS0lORF9JU1NVRRABEh8KG0JPQVJEX1JFRl9LSU5EX1BVTExfUkVRVUVTVBACKqADCgtCb2FyZFByR2F0ZRIdChlCT0FSRF9QUl9HQVRFX1VOU1BFQ0lGSUVEEAASFwoTQk9BUkRfUFJfR0FURV9EUkFGVBABEiAKHEJPQVJEX1BSX0dBVEVfQ0hFQ0tTX1BFTkRJTkcQAhIfChtCT0FSRF9QUl9HQVRFX0NIRUNLU19GQUlMRUQQAxIjCh9CT0FSRF9QUl9HQVRFX0NIQU5HRVNfUkVRVUVTVEVEEAQSIQodQk9BUkRfUFJfR0FURV9BV0FJVElOR19SRVZJRVcQBRIiCh5CT0FSRF9QUl9HQVRFX0FXQUlUSU5HX0VOUVVFVUUQBhIYChRCT0FSRF9QUl9HQVRFX01FUkdFRBAHEhgKFEJPQVJEX1BSX0dBVEVfQ0xPU0VEEAgSGAoUQk9BUkRfUFJfR0FURV9RVUVVRUQQCRIeChpCT0FSRF9QUl9HQVRFX1FVRVVFX0ZBSUxFRBAKEh8KG0JPQVJEX1BSX0dBVEVfUVVFVUVfUkVNT1ZFRBALEhsKF0JPQVJEX1BSX0dBVEVfQ09ORkxJQ1RTEAwqkgEKEEJvYXJkTWlycm9yU3RhdGUSIgoeQk9BUkRfTUlSUk9SX1NUQVRFX1VOU1BFQ0lGSUVEEAASHgoaQk9BUkRfTUlSUk9SX1NUQVRFX1BFTkRJTkcQARIbChdCT0FSRF9NSVJST1JfU1RBVEVfRE9ORRACEh0KGUJPQVJEX01JUlJPUl9TVEFURV9GQUlMRUQQAyqRAQoMUGVlcmluZ1N0YXRlEh0KGVBFRVJJTkdfU1RBVEVfVU5TUEVDSUZJRUQQABIiCh5QRUVSSU5HX1NUQVRFX1BFTkRJTkdfT1VUR09JTkcQARIiCh5QRUVSSU5HX1NUQVRFX1BFTkRJTkdfSU5DT01JTkcQAhIaChZQRUVSSU5HX1NUQVRFX0FQUFJPVkVEEAMqXgoLUm9zdGVyU2NvcGUSHQoZUk9TVEVSX1NDT1BFX05FSUdIQk9SSE9PRBAAEhgKFFJPU1RFUl9TQ09QRV9TVUJUUkVFEAESFgoSUk9TVEVSX1NDT1BFX09XTkVSEAIynBIKDENvbW1zU2VydmljZRJLCgpDcmVhdGVVc2VyEh0uY29tcGFzcy52MS5DcmVhdGVVc2VyUmVxdWVzdBoeLmNvbXBhc3MudjEuQ3JlYXRlVXNlclJlc3BvbnNlEk4KC0NyZWF0ZUFnZW50Eh4uY29tcGFzcy52MS5DcmVhdGVBZ2VudFJlcXVlc3QaHy5jb21wYXNzLnYxLkNyZWF0ZUFnZW50UmVzcG9uc2USUQoMTGlzdEFjY291bnRzEh8uY29tcGFzcy52MS5MaXN0QWNjb3VudHNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0QWNjb3VudHNSZXNwb25zZRJjChJDcmVhdGVDaGFubmVsR3JvdXASJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3QaJi5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEmAKEUxpc3RDaGFubmVsR3JvdXBzEiQuY29tcGFzcy52MS5MaXN0Q2hhbm5lbEdyb3Vwc1JlcXVlc3QaJS5jb21wYXNzLnYxLkxpc3RDaGFubmVsR3JvdXBzUmVzcG9uc2USUQoMTGlzdENoYW5uZWxzEh8uY29tcGFzcy52MS5MaXN0Q2hhbm5lbHNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0Q2hhbm5lbHNSZXNwb25zZRJUCg1DcmVhdGVDaGFubmVsEiAuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsUmVxdWVzdBohLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEmkKFFVwZGF0ZUNoYW5uZWxNZW1iZXJzEicuY29tcGFzcy52MS5VcGRhdGVDaGFubmVsTWVtYmVyc1JlcXVlc3QaKC5jb21wYXNzLnYxLlVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2USPwoGT3BlbkRNEhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0GhouY29tcGFzcy52MS5PcGVuRE1SZXNwb25zZRJUCg1SZXBhcmVudEFnZW50EiAuY29tcGFzcy52MS5SZXBhcmVudEFnZW50UmVxdWVzdBohLmNvbXBhc3MudjEuUmVwYXJlbnRBZ2VudFJlc3BvbnNlEloKD1JlcGFyZW50Q2hhbm5lbBIiLmNvbXBhc3MudjEuUmVwYXJlbnRDaGFubmVsUmVxdWVzdBojLmNvbXBhc3MudjEuUmVwYXJlbnRDaGFubmVsUmVzcG9uc2USYwoST3BlbkFnZW50V29ya3NwYWNlEiUuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0GiYuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRJRCgxMaXN0TWVzc2FnZXMSHy5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1Jlc3BvbnNlEk4KC1Bvc3RNZXNzYWdlEh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3QaHy5jb21wYXNzLnYxLlBvc3RNZXNzYWdlUmVzcG9uc2USSwoKTGlzdFRvcGljcxIdLmNvbXBhc3MudjEuTGlzdFRvcGljc1JlcXVlc3QaHi5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXNwb25zZRJOCgtVcGRhdGVUb3BpYxIeLmNvbXBhc3MudjEuVXBkYXRlVG9waWNSZXF1ZXN0Gh8uY29tcGFzcy52MS5VcGRhdGVUb3BpY1Jlc3BvbnNlElEKDFJlc3BvbmRUb0FzaxIfLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVxdWVzdBogLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVzcG9uc2USVAoNTWlycm9yVG9Gb3JnZRIgLmNvbXBhc3MudjEuTWlycm9yVG9Gb3JnZVJlcXVlc3QaIS5jb21wYXNzLnYxLk1pcnJvclRvRm9yZ2VSZXNwb25zZRJXCg5TZWFyY2hNZXNzYWdlcxIhLmNvbXBhc3MudjEuU2VhcmNoTWVzc2FnZXNSZXF1ZXN0GiIuY29tcGFzcy52MS5TZWFyY2hNZXNzYWdlc1Jlc3BvbnNlEl0KEFNldENoYW5uZWxQb2xpY3kSIy5jb21wYXNzLnYxLlNldENoYW5uZWxQb2xpY3lSZXF1ZXN0GiQuY29tcGFzcy52MS5TZXRDaGFubmVsUG9saWN5UmVzcG9uc2USSAoJR2V0Um9zdGVyEhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0Gh0uY29tcGFzcy52MS5HZXRSb3N0ZXJSZXNwb25zZRJgChFVcGRhdGVQaW5uZWRCb2FyZBIkLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXF1ZXN0GiUuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlc3BvbnNlEloKD0xpc3RBZ2VudEJvYXJkcxIiLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVxdWVzdBojLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVzcG9uc2USTgoLQXBwcm92ZVBlZXISHi5jb21wYXNzLnYxLkFwcHJvdmVQZWVyUmVxdWVzdBofLmNvbXBhc3MudjEuQXBwcm92ZVBlZXJSZXNwb25zZRJLCgpSZXZva2VQZWVyEh0uY29tcGFzcy52MS5SZXZva2VQZWVyUmVxdWVzdBoeLmNvbXBhc3MudjEuUmV2b2tlUGVlclJlc3BvbnNlEkgKCUxpc3RQZWVycxIcLmNvbXBhc3MudjEuTGlzdFBlZXJzUmVxdWVzdBodLmNvbXBhc3MudjEuTGlzdFBlZXJzUmVzcG9uc2USWQoOU3Vic2NyaWJlQ29tbXMSIS5jb21wYXNzLnYxLlN1YnNjcmliZUNvbW1zUmVxdWVzdBoiLmNvbXBhc3MudjEuU3Vic2NyaWJlQ29tbXNSZXNwb25zZTABYgZwcm90bzM", [file_compass_v1_compass]); /** * An account in the communication layer: a human user or an owned agent. Both @@ -1216,7 +1216,7 @@ export type BoardWaiting = Message$1<"compass.v1.BoardWaiting"> & { ref: string; /** - * Task keys blocked by this wait. + * References blocked by this wait. * * @generated from field: repeated string blocks = 5; */ @@ -2035,7 +2035,7 @@ export type ListAgentBoardsRequest = Message$1<"compass.v1.ListAgentBoardsReques agentHandles: string[]; /** - * Searches board content for this query. + * Searches task titles and notes for this query. * * @generated from field: string query = 2; */ @@ -2106,7 +2106,8 @@ export const ListAgentBoardsResponseSchema: GenMessage messageDesc(file_compass_v1_comms, 41); /** - * A live agent-board update; the event's board has no asks. + * An authored-only board update. Clients re-read ListAgentBoards for derived refs + * and asks. * * @generated from message compass.v1.AgentBoardChanged */ @@ -2119,7 +2120,7 @@ export type AgentBoardChanged = Message$1<"compass.v1.AgentBoardChanged"> & { agentAccountId: string; /** - * The updated board with asks omitted. + * The authored board; clients re-read ListAgentBoards for derived refs and asks. * * @generated from field: compass.v1.AgentBoard board = 2; */ @@ -4390,6 +4391,16 @@ export const CommsService: GenService<{ input: typeof UpdatePinnedBoardRequestSchema; output: typeof UpdatePinnedBoardResponseSchema; }, + /** + * List whole boards for agents the caller can see, optionally filtered. + * + * @generated from rpc compass.v1.CommsService.ListAgentBoards + */ + listAgentBoards: { + methodKind: "unary"; + input: typeof ListAgentBoardsRequestSchema; + output: typeof ListAgentBoardsResponseSchema; + }, /** * Approve a user as a peer of the caller (a user account). A peering is * live only when both users have approved each other; until then this is @@ -4426,10 +4437,9 @@ export const CommsService: GenService<{ output: typeof ListPeersResponseSchema; }, /** - * The event stream: message, channel, group, account, and workspace updates, - * scoped to the caller's visible set, with gap-free replay-on-join. The sole - * push path for the comms surface, mirroring CompassService.SubscribeEvents' - * seq/epoch replay contract (D1). + * The comms event stream, scoped to the caller's visible set, with gap-free + * replay-on-join. The sole push path for the comms surface, mirroring + * CompassService.SubscribeEvents' seq/epoch replay contract (D1). * * @generated from rpc compass.v1.CommsService.SubscribeComms */ diff --git a/packages/compass-client/src/gen/compass/v1/comms_pb.ts b/packages/compass-client/src/gen/compass/v1/comms_pb.ts index 3b37acd23..0819bbe53 100644 --- a/packages/compass-client/src/gen/compass/v1/comms_pb.ts +++ b/packages/compass-client/src/gen/compass/v1/comms_pb.ts @@ -29,7 +29,7 @@ import type { Message as Message$1 } from "@bufbuild/protobuf"; * Describes the file compass/v1/comms.proto. */ export const file_compass_v1_comms: GenFile = /*@__PURE__*/ - fileDesc("ChZjb21wYXNzL3YxL2NvbW1zLnByb3RvEgpjb21wYXNzLnYxIsQBCgdBY2NvdW50EgoKAmlkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSJwoEdXNlchgKIAEoCzIXLmNvbXBhc3MudjEuVXNlckFjY291bnRIABIpCgVhZ2VudBgLIAEoCzIYLmNvbXBhc3MudjEuQWdlbnRBY2NvdW50SAASKwoGc3lzdGVtGAwgASgLMhkuY29tcGFzcy52MS5TeXN0ZW1BY2NvdW50SABCBgoEa2luZCIPCg1TeXN0ZW1BY2NvdW50IjEKC1VzZXJBY2NvdW50EiIKBHJvbGUYASABKA4yFC5jb21wYXNzLnYxLlVzZXJSb2xlIlcKDEFnZW50QWNjb3VudBIVCg1vd25lcl91c2VyX2lkGAEgASgJEhcKD2hvbWVfY2hhbm5lbF9pZBgCIAEoCRIXCg9wYXJlbnRfYWdlbnRfaWQYAyABKAkikAEKDENoYW5uZWxHcm91cBIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhcKD3BhcmVudF9ncm91cF9pZBgDIAEoCRIVCg1vd25lcl91c2VyX2lkGAQgASgJEjYKCnZpc2liaWxpdHkYBSABKA4yIi5jb21wYXNzLnYxLkNoYW5uZWxHcm91cFZpc2liaWxpdHkijAMKB0NoYW5uZWwSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIQCghncm91cF9pZBgDIAEoCRIlCgRraW5kGAQgASgOMhcuY29tcGFzcy52MS5DaGFubmVsS2luZBIaChJtZW1iZXJfYWNjb3VudF9pZHMYBSADKAkSHgoWc3Vic2NyaWJlcl9hY2NvdW50X2lkcxgGIAMoCRIyCgtwb3N0X3BvbGljeRgHIAEoDjIdLmNvbXBhc3MudjEuQ2hhbm5lbFBvc3RQb2xpY3kSGAoQb3duZXJfYWNjb3VudF9pZBgIIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAkgASgIEi8KDnBpbm5lZF9lbnRyaWVzGAogAygLMhcuY29tcGFzcy52MS5QaW5uZWRFbnRyeRIXCg9wYXJlbnRfYWdlbnRfaWQYCyABKAkSOgoPbWVtYmVyc2hpcF9tb2RlGAwgASgOMiEuY29tcGFzcy52MS5DaGFubmVsTWVtYmVyc2hpcE1vZGUibAoLUGlubmVkRW50cnkSEgoKbWVzc2FnZV9pZBgBIAEoCRIQCghwb3NpdGlvbhgCIAEoBRIZChFwaW5uZWRfYXRfdW5peF9tcxgDIAEoAxIcChRwaW5uZWRfYnlfYWNjb3VudF9pZBgEIAEoCSI2Cg5BZ2VudFdvcmtzcGFjZRIKCgJpZBgBIAEoCRIYChBhZ2VudF9hY2NvdW50X2lkGAIgASgJIvIBCgVUb3BpYxIKCgJpZBgBIAEoCRISCgpjaGFubmVsX2lkGAIgASgJEgwKBG5hbWUYAyABKAkSGgoSY3JlYXRlZF9hdF91bml4X21zGAQgASgDEh0KFWNyZWF0ZWRfYnlfYWNjb3VudF9pZBgFIAEoCRIQCghhcmNoaXZlZBgGIAEoCBIaCg1tZXNzYWdlX2NvdW50GAcgASgESACIAQESJAoXbGFzdF9tZXNzYWdlX2F0X3VuaXhfbXMYCCABKANIAYgBAUIQCg5fbWVzc2FnZV9jb3VudEIaChhfbGFzdF9tZXNzYWdlX2F0X3VuaXhfbXMiwgEKB01lc3NhZ2USCgoCaWQYASABKAkSEAoIdG9waWNfaWQYAiABKAkSGQoRYXV0aG9yX2FjY291bnRfaWQYAyABKAkSEgoKYXRfdW5peF9tcxgEIAEoAxIoCgZibG9ja3MYBSADKAsyGC5jb21wYXNzLnYxLk1lc3NhZ2VCbG9jaxIVCg1hdXRob3JfaGFuZGxlGAYgASgJEhIKCmNoYW5uZWxfaWQYByABKAkSFQoNdHVybl9zZXF1ZW5jZRgIIAEoBCJ5CgxNZXNzYWdlQmxvY2sSDgoEdGV4dBgBIAEoCUgAEh4KA2FzaxgCIAEoCzIPLmNvbXBhc3MudjEuQXNrSAASMAoKYXNrX2Fuc3dlchgDIAEoCzIaLmNvbXBhc3MudjEuQXNrQW5zd2VyQmxvY2tIAEIHCgVibG9jayJICg5Bc2tBbnN3ZXJCbG9jaxIcCgNhc2sYASABKAsyDy5jb21wYXNzLnYxLkFzaxIYChBhc2tlcl9hY2NvdW50X2lkGAIgASgJImAKA0FzaxIOCgZhc2tfaWQYASABKAkSKgoJcXVlc3Rpb25zGAIgAygLMhcuY29tcGFzcy52MS5Bc2tRdWVzdGlvbhIQCghhbnN3ZXJlZBgDIAEoCBILCgNyZWYYBCABKAki8QEKC0Fza1F1ZXN0aW9uEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhAKCHF1ZXN0aW9uGAIgASgJEg4KBmhlYWRlchgDIAEoCRImCgdvcHRpb25zGAQgAygLMhUuY29tcGFzcy52MS5Bc2tPcHRpb24SFgoOYWxsb3dfbXVsdGlwbGUYBSABKAgSGAoLcmVjb21tZW5kZWQYBiABKAVIAIgBARIZChFjaG9zZW5fb3B0aW9uX2lkcxgHIAMoCRITCgtjdXN0b21fdGV4dBgIIAEoCRIRCgl0aW1lZF9vdXQYCSABKAhCDgoMX3JlY29tbWVuZGVkIkwKCUFza09wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdwcmV2aWV3GAQgASgJIscFChZTdWJzY3JpYmVDb21tc1Jlc3BvbnNlEgsKA3NlcRgBIAEoBBISCgphdF91bml4X21zGAIgASgDEhYKDmluc3RhbmNlX2Vwb2NoGAMgASgEEhQKDHNuYXBzaG90X3NlcRgEIAEoBBIzCg5tZXNzYWdlX3Bvc3RlZBgKIAEoCzIZLmNvbXBhc3MudjEuTWVzc2FnZVBvc3RlZEgAEjUKD21lc3NhZ2VfdXBkYXRlZBgLIAEoCzIaLmNvbXBhc3MudjEuTWVzc2FnZVVwZGF0ZWRIABI1Cg9jaGFubmVsX2NoYW5nZWQYDCABKAsyGi5jb21wYXNzLnYxLkNoYW5uZWxDaGFuZ2VkSAASQAoVY2hhbm5lbF9ncm91cF9jaGFuZ2VkGA0gASgLMh8uY29tcGFzcy52MS5DaGFubmVsR3JvdXBDaGFuZ2VkSAASNQoPYWNjb3VudF9jaGFuZ2VkGA4gASgLMhouY29tcGFzcy52MS5BY2NvdW50Q2hhbmdlZEgAEkQKF2FnZW50X3dvcmtzcGFjZV9jaGFuZ2VkGA8gASgLMiEuY29tcGFzcy52MS5BZ2VudFdvcmtzcGFjZUNoYW5nZWRIABI6Cg9yZXN5bmNfcmVxdWlyZWQYECABKAsyHy5jb21wYXNzLnYxLkNvbW1zUmVzeW5jUmVxdWlyZWRIABJCChZhZ2VudF9wcmVzZW5jZV9jaGFuZ2VkGBEgASgLMiAuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlQ2hhbmdlZEgAEjMKDnRvcGljX3Vwc2VydGVkGBIgASgLMhkuY29tcGFzcy52MS5Ub3BpY1Vwc2VydGVkSAASPAoTYWdlbnRfYm9hcmRfY2hhbmdlZBgTIAEoCzIdLmNvbXBhc3MudjEuQWdlbnRCb2FyZENoYW5nZWRIAEIJCgdwYXlsb2FkIkwKDU1lc3NhZ2VQb3N0ZWQSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZRIVCg10dXJuX3NlcXVlbmNlGAIgASgEIjYKDk1lc3NhZ2VVcGRhdGVkEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiMQoNVG9waWNVcHNlcnRlZBIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiUwoOQ2hhbm5lbENoYW5nZWQSJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbBIbChNyZW1vdmVkX2FjY291bnRfaWRzGAIgAygJIj4KE0NoYW5uZWxHcm91cENoYW5nZWQSJwoFZ3JvdXAYASABKAsyGC5jb21wYXNzLnYxLkNoYW5uZWxHcm91cCI2Cg5BY2NvdW50Q2hhbmdlZBIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50IkYKFUFnZW50V29ya3NwYWNlQ2hhbmdlZBItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIhUKE0NvbW1zUmVzeW5jUmVxdWlyZWQibwoUQWdlbnRQcmVzZW5jZUNoYW5nZWQSGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIrCghwcmVzZW5jZRgCIAEoDjIZLmNvbXBhc3MudjEuQWdlbnRQcmVzZW5jZRIQCghhY3Rpdml0eRgDIAEoCSJ3CgxCb2FyZFdhaXRpbmcSJgoCb24YASABKA4yGi5jb21wYXNzLnYxLkJvYXJkV2FpdGluZ09uEhQKDGFnZW50X2hhbmRsZRgCIAEoCRIMCgR3aGF0GAMgASgJEgsKA3JlZhgEIAEoCRIOCgZibG9ja3MYBSADKAkihQEKDUJvYXJkU3ViYWdlbnQSCgoCaWQYASABKAkSDQoFYWdlbnQYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSDgoGc3RhdHVzGAQgASgJEhoKEnN0YXJ0ZWRfYXRfdW5peF9tcxgFIAEoAxIYChBlbmRlZF9hdF91bml4X21zGAYgASgDIvgBCglCb2FyZFRhc2sSCwoDa2V5GAEgASgJEg0KBXRpdGxlGAIgASgJEhMKC3ByaW1hcnlfcmVmGAMgASgJEgwKBHJlZnMYBCADKAkSKQoFc3RhdGUYBSABKA4yGi5jb21wYXNzLnYxLkJvYXJkVGFza1N0YXRlEgwKBG5vdGUYBiABKAkSKQoHd2FpdGluZxgHIAMoCzIYLmNvbXBhc3MudjEuQm9hcmRXYWl0aW5nEiwKCXN1YmFnZW50cxgIIAMoCzIZLmNvbXBhc3MudjEuQm9hcmRTdWJhZ2VudBIaChJ3cml0dGVuX2F0X3VuaXhfbXMYCSABKAMiHQoLQm9hcmRSZWZzT3ASDgoGdmFsdWVzGAEgAygJIjoKDkJvYXJkV2FpdGluZ09wEigKBnZhbHVlcxgBIAMoCzIYLmNvbXBhc3MudjEuQm9hcmRXYWl0aW5nIsQCCg5Cb2FyZFRhc2tQYXRjaBILCgNrZXkYASABKAkSEgoFdGl0bGUYAiABKAlIAIgBARIYCgtwcmltYXJ5X3JlZhgDIAEoCUgBiAEBEi4KBXN0YXRlGAQgASgOMhouY29tcGFzcy52MS5Cb2FyZFRhc2tTdGF0ZUgCiAEBEhEKBG5vdGUYBSABKAlIA4gBARIlCgRyZWZzGAYgASgLMhcuY29tcGFzcy52MS5Cb2FyZFJlZnNPcBIrCgd3YWl0aW5nGAcgASgLMhouY29tcGFzcy52MS5Cb2FyZFdhaXRpbmdPcBIzChB1cHNlcnRfc3ViYWdlbnRzGAggAygLMhkuY29tcGFzcy52MS5Cb2FyZFN1YmFnZW50QggKBl90aXRsZUIOCgxfcHJpbWFyeV9yZWZCCAoGX3N0YXRlQgcKBV9ub3RlIlEKCUJvYXJkSGFsdBInCgRraW5kGAEgASgOMhkuY29tcGFzcy52MS5Cb2FyZEhhbHRLaW5kEgsKA3JlZhgCIAEoCRIOCgZyZWFzb24YAyABKAkijwMKCEJvYXJkUmVmEgsKA3JlZhgBIAEoCRImCgRraW5kGAIgASgOMhguY29tcGFzcy52MS5Cb2FyZFJlZktpbmQSDQoFdGl0bGUYAyABKAkSCwoDdXJsGAQgASgJEhMKC2ZvcmdlX3N0YXRlGAUgASgJEisKC2lzc3VlX3N0YXRlGAYgASgOMhYuY29tcGFzcy52MS5Jc3N1ZVN0YXRlEg0KBWRyYWZ0GAcgASgIEhQKDGNoZWNrc19zdGF0ZRgIIAEoCRIlCgRnYXRlGAkgASgOMhcuY29tcGFzcy52MS5Cb2FyZFByR2F0ZRIVCg1zdGFja190aXBfcmVmGAogASgJEhYKDnN0YWNrX3Bvc2l0aW9uGAsgASgNEg0KBW93bmVkGAwgASgIEhsKE29ic2VydmVkX2F0X3VuaXhfbXMYDSABKAMSGQoRbWVyZ2VfcXVldWVfc3RhdGUYDiABKAkSFgoOc3RhbGVfYXBwcm92YWwYDyABKAgSFgoOdHJhY2tlcl9zdGF0dXMYECABKAki1AEKCEJvYXJkQXNrEg4KBmFza19pZBgBIAEoCRISCgptZXNzYWdlX2lkGAIgASgJEhIKCmNoYW5uZWxfaWQYAyABKAkSCwoDcmVmGAQgASgJEg4KBmhlYWRlchgFIAEoCRIQCghhbnN3ZXJlZBgGIAEoCBIZChFwb3N0ZWRfYXRfdW5peF9tcxgHIAEoAxIyCgxtaXJyb3Jfc3RhdGUYCCABKA4yHC5jb21wYXNzLnYxLkJvYXJkTWlycm9yU3RhdGUSEgoKbWlycm9yX3VybBgJIAEoCSJuChRNaXJyb3JUb0ZvcmdlUmVxdWVzdBILCgNyZWYYASABKAkSDgoGYXNrX2lkGAIgASgJEhAKCHRvcGljX2lkGAMgASgJEhMKC21lc3NhZ2VfaWRzGAQgAygJEhIKCmNoYW5uZWxfaWQYBSABKAkiLAoVTWlycm9yVG9Gb3JnZVJlc3BvbnNlEhMKC2NvbW1lbnRfdXJsGAEgASgJItUCCgpBZ2VudEJvYXJkEhgKEGFnZW50X2FjY291bnRfaWQYASABKAkSDgoGaGFuZGxlGAIgASgJEhYKDmZvY3VzX3Rhc2tfa2V5GAMgASgJEiMKBGhhbHQYBCABKAsyFS5jb21wYXNzLnYxLkJvYXJkSGFsdBIkCgV0YXNrcxgFIAMoCzIVLmNvbXBhc3MudjEuQm9hcmRUYXNrEiIKBHJlZnMYBiADKAsyFC5jb21wYXNzLnYxLkJvYXJkUmVmEhYKDnVudHJhY2tlZF9yZWZzGAcgAygJEiIKBGFza3MYCCADKAsyFC5jb21wYXNzLnYxLkJvYXJkQXNrEhQKDHdyaXR0ZW5fdHVybhgJIAEoBBIaChJ3cml0dGVuX2F0X3VuaXhfbXMYCiABKAMSDQoFc3RhbGUYCyABKAgSGQoRbWF0Y2hlZF90YXNrX2tleXMYDCADKAkiowEKEUFnZW50Qm9hcmRTdW1tYXJ5Eg0KBWZvY3VzGAEgASgJEhIKCnRhc2tfY291bnQYAiABKA0SGwoTd2FpdGluZ19vbl9vcGVyYXRvchgDIAEoDRIjCgRoYWx0GAQgASgLMhUuY29tcGFzcy52MS5Cb2FyZEhhbHQSGgoSd3JpdHRlbl9hdF91bml4X21zGAUgASgDEg0KBXN0YWxlGAYgASgIIvgBChdVcGRhdGVBZ2VudEJvYXJkUmVxdWVzdBIwCgx1cHNlcnRfdGFza3MYASADKAsyGi5jb21wYXNzLnYxLkJvYXJkVGFza1BhdGNoEhgKEHJlbW92ZV90YXNrX2tleXMYAiADKAkSGwoOZm9jdXNfdGFza19rZXkYAyABKAlIAIgBARIjCgRoYWx0GAQgASgLMhUuY29tcGFzcy52MS5Cb2FyZEhhbHQSEgoKY2xlYXJfaGFsdBgFIAEoCBIVCg10dXJuX3NlcXVlbmNlGAYgASgEEhEKCWF1dG9tYXRpYxgHIAEoCEIRCg9fZm9jdXNfdGFza19rZXkiQQoYVXBkYXRlQWdlbnRCb2FyZFJlc3BvbnNlEiUKBWJvYXJkGAEgASgLMhYuY29tcGFzcy52MS5BZ2VudEJvYXJkIt4BChZMaXN0QWdlbnRCb2FyZHNSZXF1ZXN0EhUKDWFnZW50X2hhbmRsZXMYASADKAkSDQoFcXVlcnkYAiABKAkSCwoDcmVmGAMgASgJEi4KCndhaXRpbmdfb24YBCABKA4yGi5jb21wYXNzLnYxLkJvYXJkV2FpdGluZ09uEikKBXN0YXRlGAUgASgOMhouY29tcGFzcy52MS5Cb2FyZFRhc2tTdGF0ZRInCgRoYWx0GAYgASgOMhkuY29tcGFzcy52MS5Cb2FyZEhhbHRLaW5kEg0KBWxpbWl0GAcgASgNIkEKF0xpc3RBZ2VudEJvYXJkc1Jlc3BvbnNlEiYKBmJvYXJkcxgBIAMoCzIWLmNvbXBhc3MudjEuQWdlbnRCb2FyZCJUChFBZ2VudEJvYXJkQ2hhbmdlZBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEiUKBWJvYXJkGAIgASgLMhYuY29tcGFzcy52MS5BZ2VudEJvYXJkIlsKB1BlZXJpbmcSFwoPdXNlcl9hY2NvdW50X2lkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRInCgVzdGF0ZRgDIAEoDjIYLmNvbXBhc3MudjEuUGVlcmluZ1N0YXRlIikKEkFwcHJvdmVQZWVyUmVxdWVzdBITCgtwZWVyX2hhbmRsZRgBIAEoCSI7ChNBcHByb3ZlUGVlclJlc3BvbnNlEiQKB3BlZXJpbmcYASABKAsyEy5jb21wYXNzLnYxLlBlZXJpbmciKAoRUmV2b2tlUGVlclJlcXVlc3QSEwoLcGVlcl9oYW5kbGUYASABKAkiJQoSUmV2b2tlUGVlclJlc3BvbnNlEg8KB2RlbGV0ZWQYASABKAgiEgoQTGlzdFBlZXJzUmVxdWVzdCI6ChFMaXN0UGVlcnNSZXNwb25zZRIlCghwZWVyaW5ncxgBIAMoCzITLmNvbXBhc3MudjEuUGVlcmluZyI5ChFDcmVhdGVVc2VyUmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJIjoKEkNyZWF0ZVVzZXJSZXNwb25zZRIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50InAKEkNyZWF0ZUFnZW50UmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhUKDXBhcmVudF9oYW5kbGUYAyABKAkSDAoEcm9sZRgEIAEoCRIPCgdwZXJzb25hGAUgASgJIjsKE0NyZWF0ZUFnZW50UmVzcG9uc2USJAoHYWNjb3VudBgBIAEoCzITLmNvbXBhc3MudjEuQWNjb3VudCIrChNMaXN0QWNjb3VudHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCI9ChRMaXN0QWNjb3VudHNSZXNwb25zZRIlCghhY2NvdW50cxgBIAMoCzITLmNvbXBhc3MudjEuQWNjb3VudCKVAQoZQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEhcKD3BhcmVudF9ncm91cF9pZBgCIAEoCRI2Cgp2aXNpYmlsaXR5GAMgASgOMiIuY29tcGFzcy52MS5DaGFubmVsR3JvdXBWaXNpYmlsaXR5EhkKEXBhcmVudF9ncm91cF9uYW1lGAQgASgJIkUKGkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEicKBWdyb3VwGAEgASgLMhguY29tcGFzcy52MS5DaGFubmVsR3JvdXAiMAoYTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCJFChlMaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlEigKBmdyb3VwcxgBIAMoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIisKE0xpc3RDaGFubmVsc1JlcXVlc3QSFAoMc25hcHNob3Rfc2VxGAEgASgEIj0KFExpc3RDaGFubmVsc1Jlc3BvbnNlEiUKCGNoYW5uZWxzGAEgAygLMhMuY29tcGFzcy52MS5DaGFubmVsIuIBChRDcmVhdGVDaGFubmVsUmVxdWVzdBIMCgRuYW1lGAEgASgJEhAKCGdyb3VwX2lkGAIgASgJEiUKBGtpbmQYAyABKA4yFy5jb21wYXNzLnYxLkNoYW5uZWxLaW5kEhYKDm1lbWJlcl9oYW5kbGVzGAQgAygJEhsKE3BhcmVudF9hZ2VudF9oYW5kbGUYBSABKAkSOgoPbWVtYmVyc2hpcF9tb2RlGAYgASgOMiEuY29tcGFzcy52MS5DaGFubmVsTWVtYmVyc2hpcE1vZGUSEgoKZ3JvdXBfbmFtZRgHIAEoCSI9ChVDcmVhdGVDaGFubmVsUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCLCAQobVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSGgoSYWRkX21lbWJlcl9oYW5kbGVzGAIgAygJEh0KFXJlbW92ZV9tZW1iZXJfaGFuZGxlcxgDIAMoCRIZChFzdWJzY3JpYmVfaGFuZGxlcxgEIAMoCRIbChN1bnN1YnNjcmliZV9oYW5kbGVzGAUgAygJEhwKFGNvbnZlcnRfY2hhbm5lbF9uYW1lGAYgASgJIkQKHFVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIkCg1PcGVuRE1SZXF1ZXN0EhMKC3BlZXJfaGFuZGxlGAEgASgJIkcKDk9wZW5ETVJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwSDwoHY3JlYXRlZBgCIAEoCCJHChRSZXBhcmVudEFnZW50UmVxdWVzdBIUCgxhZ2VudF9oYW5kbGUYASABKAkSGQoRbmV3X3BhcmVudF9oYW5kbGUYAiABKAkiPQoVUmVwYXJlbnRBZ2VudFJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiTQoWUmVwYXJlbnRDaGFubmVsUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEh8KF25ld19wYXJlbnRfYWdlbnRfaGFuZGxlGAIgASgJIj8KF1JlcGFyZW50Q2hhbm5lbFJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwilwEKF1NldENoYW5uZWxQb2xpY3lSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSMgoLcG9zdF9wb2xpY3kYAiABKA4yHS5jb21wYXNzLnYxLkNoYW5uZWxQb3N0UG9saWN5EhQKDG93bmVyX2hhbmRsZRgDIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAQgASgIIkAKGFNldENoYW5uZWxQb2xpY3lSZXNwb25zZRIkCgdjaGFubmVsGAEgASgLMhMuY29tcGFzcy52MS5DaGFubmVsIlIKEEdldFJvc3RlclJlcXVlc3QSJgoFc2NvcGUYASABKA4yFy5jb21wYXNzLnYxLlJvc3RlclNjb3BlEhYKDnZhbnRhZ2VfaGFuZGxlGAIgASgJIj0KEUdldFJvc3RlclJlc3BvbnNlEigKB2VudHJpZXMYASADKAsyFy5jb21wYXNzLnYxLlJvc3RlckVudHJ5IvABCgtSb3N0ZXJFbnRyeRIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSFwoPcGFyZW50X2FnZW50X2lkGAQgASgJEisKCHByZXNlbmNlGAUgASgOMhkuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlEhAKCGFjdGl2aXR5GAYgASgJEhsKE2FjdGl2aXR5X2F0X3VuaXhfbXMYByABKAMSLAoFYm9hcmQYCCABKAsyHS5jb21wYXNzLnYxLkFnZW50Qm9hcmRTdW1tYXJ5IncKGFVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEiUKA3BpbhgCIAEoCzIWLmNvbXBhc3MudjEuUGluTWVzc2FnZUgAEhoKEHVucGluX21lc3NhZ2VfaWQYAyABKAlIAEIECgJvcCI8CgpQaW5NZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSGgoScmVwbGFjZV9tZXNzYWdlX2lkGAIgASgJIkEKGVVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIxChlPcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSJLChpPcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIooBChNMaXN0TWVzc2FnZXNSZXF1ZXN0EhQKCmNoYW5uZWxfaWQYASABKAlIABINCgVsaW1pdBgCIAEoDRIZChFiZWZvcmVfbWVzc2FnZV9pZBgDIAEoCRIUCgxzbmFwc2hvdF9zZXEYBCABKAQSEAoIdG9waWNfaWQYBSABKAlCCwoJY29udGFpbmVyIj0KFExpc3RNZXNzYWdlc1Jlc3BvbnNlEiUKCG1lc3NhZ2VzGAEgAygLMhMuY29tcGFzcy52MS5NZXNzYWdlItwBChJQb3N0TWVzc2FnZVJlcXVlc3QSFAoKY2hhbm5lbF9pZBgBIAEoCUgAEigKBmJsb2NrcxgCIAMoCzIYLmNvbXBhc3MudjEuTWVzc2FnZUJsb2NrEhIKCHRvcGljX2lkGAMgASgJSAESFAoKdG9waWNfbmFtZRgEIAEoCUgBEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAUgASgJEhQKDGNyZWF0ZV90b3BpYxgGIAEoCBIVCg10dXJuX3NlcXVlbmNlGAcgASgEQgsKCWNvbnRhaW5lckIHCgV0b3BpYyI7ChNQb3N0TWVzc2FnZVJlc3BvbnNlEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiQQoRTGlzdFRvcGljc1JlcXVlc3QSEgoKY2hhbm5lbF9pZBgBIAEoCRIYChBpbmNsdWRlX2FyY2hpdmVkGAIgASgIIjcKEkxpc3RUb3BpY3NSZXNwb25zZRIhCgZ0b3BpY3MYASADKAsyES5jb21wYXNzLnYxLlRvcGljImYKElVwZGF0ZVRvcGljUmVxdWVzdBIQCgh0b3BpY19pZBgBIAEoCRIRCgRuYW1lGAIgASgJSACIAQESFQoIYXJjaGl2ZWQYAyABKAhIAYgBAUIHCgVfbmFtZUILCglfYXJjaGl2ZWQiNwoTVXBkYXRlVG9waWNSZXNwb25zZRIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiVQoTUmVzcG9uZFRvQXNrUmVxdWVzdBIOCgZhc2tfaWQYASABKAkSLgoHYW5zd2VycxgCIAMoCzIdLmNvbXBhc3MudjEuQXNrUXVlc3Rpb25BbnN3ZXIiWAoRQXNrUXVlc3Rpb25BbnN3ZXISEwoLcXVlc3Rpb25faWQYASABKAkSGQoRY2hvc2VuX29wdGlvbl9pZHMYAiADKAkSEwoLY3VzdG9tX3RleHQYAyABKAkiFgoUUmVzcG9uZFRvQXNrUmVzcG9uc2UiagoVU2VhcmNoTWVzc2FnZXNSZXF1ZXN0Eg0KBXF1ZXJ5GAEgASgJEhQKCmNoYW5uZWxfaWQYAiABKAlIABINCgVsaW1pdBgDIAEoDRIUCgxzbmFwc2hvdF9zZXEYBCABKARCBwoFc2NvcGUiPwoWU2VhcmNoTWVzc2FnZXNSZXNwb25zZRIlCghtZXNzYWdlcxgBIAMoCzITLmNvbXBhc3MudjEuTWVzc2FnZSJCChVTdWJzY3JpYmVDb21tc1JlcXVlc3QSEQoJc2luY2Vfc2VxGAEgASgEEhYKDmluc3RhbmNlX2Vwb2NoGAIgASgEKjUKCFVzZXJSb2xlEhQKEFVTRVJfUk9MRV9NRU1CRVIQABITCg9VU0VSX1JPTEVfQURNSU4QASphChZDaGFubmVsR3JvdXBWaXNpYmlsaXR5EiIKHkNIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9PV05FUhAAEiMKH0NIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9TSEFSRUQQASpVChFDaGFubmVsUG9zdFBvbGljeRIcChhDSEFOTkVMX1BPU1RfUE9MSUNZX09QRU4QABIiCh5DSEFOTkVMX1BPU1RfUE9MSUNZX09XTkVSX09OTFkQASpfChVDaGFubmVsTWVtYmVyc2hpcE1vZGUSJAogQ0hBTk5FTF9NRU1CRVJTSElQX01PREVfRVhQTElDSVQQABIgChxDSEFOTkVMX01FTUJFUlNISVBfTU9ERV9UUkVFEAEqWwoLQ2hhbm5lbEtpbmQSGAoUQ0hBTk5FTF9LSU5EX0NIQU5ORUwQABITCg9DSEFOTkVMX0tJTkRfRE0QARIdChVDSEFOTkVMX0tJTkRfR1JPVVBfRE0QAhoCCAEqnAEKDUFnZW50UHJlc2VuY2USHgoaQUdFTlRfUFJFU0VOQ0VfVU5TUEVDSUZJRUQQABIXChNBR0VOVF9QUkVTRU5DRV9JRExFEAESGgoWQUdFTlRfUFJFU0VOQ0VfV09SS0lORxACEhoKFkFHRU5UX1BSRVNFTkNFX1dBSVRJTkcQAxIaChZBR0VOVF9QUkVTRU5DRV9PRkZMSU5FEAQqwwEKDkJvYXJkVGFza1N0YXRlEiAKHEJPQVJEX1RBU0tfU1RBVEVfVU5TUEVDSUZJRUQQABIbChdCT0FSRF9UQVNLX1NUQVRFX0FDVElWRRABEhwKGEJPQVJEX1RBU0tfU1RBVEVfQkxPQ0tFRBACEhwKGEJPQVJEX1RBU0tfU1RBVEVfV0FJVElORxADEhsKF0JPQVJEX1RBU0tfU1RBVEVfUEFSS0VEEAQSGQoVQk9BUkRfVEFTS19TVEFURV9ET05FEAUqigEKDUJvYXJkSGFsdEtpbmQSHwobQk9BUkRfSEFMVF9LSU5EX1VOU1BFQ0lGSUVEEAASGAoUQk9BUkRfSEFMVF9LSU5EX0RPTkUQARIbChdCT0FSRF9IQUxUX0tJTkRfQkxPQ0tFRBACEiEKHUJPQVJEX0hBTFRfS0lORF9BV0FJVElOR19VU0VSEAMqjAEKDkJvYXJkV2FpdGluZ09uEiAKHEJPQVJEX1dBSVRJTkdfT05fVU5TUEVDSUZJRUQQABIdChlCT0FSRF9XQUlUSU5HX09OX09QRVJBVE9SEAESGgoWQk9BUkRfV0FJVElOR19PTl9BR0VOVBACEh0KGUJPQVJEX1dBSVRJTkdfT05fRVhURVJOQUwQAyppCgxCb2FyZFJlZktpbmQSHgoaQk9BUkRfUkVGX0tJTkRfVU5TUEVDSUZJRUQQABIYChRCT0FSRF9SRUZfS0lORF9JU1NVRRABEh8KG0JPQVJEX1JFRl9LSU5EX1BVTExfUkVRVUVTVBACKqADCgtCb2FyZFByR2F0ZRIdChlCT0FSRF9QUl9HQVRFX1VOU1BFQ0lGSUVEEAASFwoTQk9BUkRfUFJfR0FURV9EUkFGVBABEiAKHEJPQVJEX1BSX0dBVEVfQ0hFQ0tTX1BFTkRJTkcQAhIfChtCT0FSRF9QUl9HQVRFX0NIRUNLU19GQUlMRUQQAxIjCh9CT0FSRF9QUl9HQVRFX0NIQU5HRVNfUkVRVUVTVEVEEAQSIQodQk9BUkRfUFJfR0FURV9BV0FJVElOR19SRVZJRVcQBRIiCh5CT0FSRF9QUl9HQVRFX0FXQUlUSU5HX0VOUVVFVUUQBhIYChRCT0FSRF9QUl9HQVRFX01FUkdFRBAHEhgKFEJPQVJEX1BSX0dBVEVfQ0xPU0VEEAgSGAoUQk9BUkRfUFJfR0FURV9RVUVVRUQQCRIeChpCT0FSRF9QUl9HQVRFX1FVRVVFX0ZBSUxFRBAKEh8KG0JPQVJEX1BSX0dBVEVfUVVFVUVfUkVNT1ZFRBALEhsKF0JPQVJEX1BSX0dBVEVfQ09ORkxJQ1RTEAwqkgEKEEJvYXJkTWlycm9yU3RhdGUSIgoeQk9BUkRfTUlSUk9SX1NUQVRFX1VOU1BFQ0lGSUVEEAASHgoaQk9BUkRfTUlSUk9SX1NUQVRFX1BFTkRJTkcQARIbChdCT0FSRF9NSVJST1JfU1RBVEVfRE9ORRACEh0KGUJPQVJEX01JUlJPUl9TVEFURV9GQUlMRUQQAyqRAQoMUGVlcmluZ1N0YXRlEh0KGVBFRVJJTkdfU1RBVEVfVU5TUEVDSUZJRUQQABIiCh5QRUVSSU5HX1NUQVRFX1BFTkRJTkdfT1VUR09JTkcQARIiCh5QRUVSSU5HX1NUQVRFX1BFTkRJTkdfSU5DT01JTkcQAhIaChZQRUVSSU5HX1NUQVRFX0FQUFJPVkVEEAMqXgoLUm9zdGVyU2NvcGUSHQoZUk9TVEVSX1NDT1BFX05FSUdIQk9SSE9PRBAAEhgKFFJPU1RFUl9TQ09QRV9TVUJUUkVFEAESFgoSUk9TVEVSX1NDT1BFX09XTkVSEAIywBEKDENvbW1zU2VydmljZRJLCgpDcmVhdGVVc2VyEh0uY29tcGFzcy52MS5DcmVhdGVVc2VyUmVxdWVzdBoeLmNvbXBhc3MudjEuQ3JlYXRlVXNlclJlc3BvbnNlEk4KC0NyZWF0ZUFnZW50Eh4uY29tcGFzcy52MS5DcmVhdGVBZ2VudFJlcXVlc3QaHy5jb21wYXNzLnYxLkNyZWF0ZUFnZW50UmVzcG9uc2USUQoMTGlzdEFjY291bnRzEh8uY29tcGFzcy52MS5MaXN0QWNjb3VudHNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0QWNjb3VudHNSZXNwb25zZRJjChJDcmVhdGVDaGFubmVsR3JvdXASJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3QaJi5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEmAKEUxpc3RDaGFubmVsR3JvdXBzEiQuY29tcGFzcy52MS5MaXN0Q2hhbm5lbEdyb3Vwc1JlcXVlc3QaJS5jb21wYXNzLnYxLkxpc3RDaGFubmVsR3JvdXBzUmVzcG9uc2USUQoMTGlzdENoYW5uZWxzEh8uY29tcGFzcy52MS5MaXN0Q2hhbm5lbHNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0Q2hhbm5lbHNSZXNwb25zZRJUCg1DcmVhdGVDaGFubmVsEiAuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsUmVxdWVzdBohLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEmkKFFVwZGF0ZUNoYW5uZWxNZW1iZXJzEicuY29tcGFzcy52MS5VcGRhdGVDaGFubmVsTWVtYmVyc1JlcXVlc3QaKC5jb21wYXNzLnYxLlVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2USPwoGT3BlbkRNEhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0GhouY29tcGFzcy52MS5PcGVuRE1SZXNwb25zZRJUCg1SZXBhcmVudEFnZW50EiAuY29tcGFzcy52MS5SZXBhcmVudEFnZW50UmVxdWVzdBohLmNvbXBhc3MudjEuUmVwYXJlbnRBZ2VudFJlc3BvbnNlEloKD1JlcGFyZW50Q2hhbm5lbBIiLmNvbXBhc3MudjEuUmVwYXJlbnRDaGFubmVsUmVxdWVzdBojLmNvbXBhc3MudjEuUmVwYXJlbnRDaGFubmVsUmVzcG9uc2USYwoST3BlbkFnZW50V29ya3NwYWNlEiUuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0GiYuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRJRCgxMaXN0TWVzc2FnZXMSHy5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1Jlc3BvbnNlEk4KC1Bvc3RNZXNzYWdlEh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3QaHy5jb21wYXNzLnYxLlBvc3RNZXNzYWdlUmVzcG9uc2USSwoKTGlzdFRvcGljcxIdLmNvbXBhc3MudjEuTGlzdFRvcGljc1JlcXVlc3QaHi5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXNwb25zZRJOCgtVcGRhdGVUb3BpYxIeLmNvbXBhc3MudjEuVXBkYXRlVG9waWNSZXF1ZXN0Gh8uY29tcGFzcy52MS5VcGRhdGVUb3BpY1Jlc3BvbnNlElEKDFJlc3BvbmRUb0FzaxIfLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVxdWVzdBogLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVzcG9uc2USVAoNTWlycm9yVG9Gb3JnZRIgLmNvbXBhc3MudjEuTWlycm9yVG9Gb3JnZVJlcXVlc3QaIS5jb21wYXNzLnYxLk1pcnJvclRvRm9yZ2VSZXNwb25zZRJXCg5TZWFyY2hNZXNzYWdlcxIhLmNvbXBhc3MudjEuU2VhcmNoTWVzc2FnZXNSZXF1ZXN0GiIuY29tcGFzcy52MS5TZWFyY2hNZXNzYWdlc1Jlc3BvbnNlEl0KEFNldENoYW5uZWxQb2xpY3kSIy5jb21wYXNzLnYxLlNldENoYW5uZWxQb2xpY3lSZXF1ZXN0GiQuY29tcGFzcy52MS5TZXRDaGFubmVsUG9saWN5UmVzcG9uc2USSAoJR2V0Um9zdGVyEhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0Gh0uY29tcGFzcy52MS5HZXRSb3N0ZXJSZXNwb25zZRJgChFVcGRhdGVQaW5uZWRCb2FyZBIkLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXF1ZXN0GiUuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlc3BvbnNlEk4KC0FwcHJvdmVQZWVyEh4uY29tcGFzcy52MS5BcHByb3ZlUGVlclJlcXVlc3QaHy5jb21wYXNzLnYxLkFwcHJvdmVQZWVyUmVzcG9uc2USSwoKUmV2b2tlUGVlchIdLmNvbXBhc3MudjEuUmV2b2tlUGVlclJlcXVlc3QaHi5jb21wYXNzLnYxLlJldm9rZVBlZXJSZXNwb25zZRJICglMaXN0UGVlcnMSHC5jb21wYXNzLnYxLkxpc3RQZWVyc1JlcXVlc3QaHS5jb21wYXNzLnYxLkxpc3RQZWVyc1Jlc3BvbnNlElkKDlN1YnNjcmliZUNvbW1zEiEuY29tcGFzcy52MS5TdWJzY3JpYmVDb21tc1JlcXVlc3QaIi5jb21wYXNzLnYxLlN1YnNjcmliZUNvbW1zUmVzcG9uc2UwAWIGcHJvdG8z", [file_compass_v1_compass]); + fileDesc("ChZjb21wYXNzL3YxL2NvbW1zLnByb3RvEgpjb21wYXNzLnYxIsQBCgdBY2NvdW50EgoKAmlkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSJwoEdXNlchgKIAEoCzIXLmNvbXBhc3MudjEuVXNlckFjY291bnRIABIpCgVhZ2VudBgLIAEoCzIYLmNvbXBhc3MudjEuQWdlbnRBY2NvdW50SAASKwoGc3lzdGVtGAwgASgLMhkuY29tcGFzcy52MS5TeXN0ZW1BY2NvdW50SABCBgoEa2luZCIPCg1TeXN0ZW1BY2NvdW50IjEKC1VzZXJBY2NvdW50EiIKBHJvbGUYASABKA4yFC5jb21wYXNzLnYxLlVzZXJSb2xlIlcKDEFnZW50QWNjb3VudBIVCg1vd25lcl91c2VyX2lkGAEgASgJEhcKD2hvbWVfY2hhbm5lbF9pZBgCIAEoCRIXCg9wYXJlbnRfYWdlbnRfaWQYAyABKAkikAEKDENoYW5uZWxHcm91cBIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJEhcKD3BhcmVudF9ncm91cF9pZBgDIAEoCRIVCg1vd25lcl91c2VyX2lkGAQgASgJEjYKCnZpc2liaWxpdHkYBSABKA4yIi5jb21wYXNzLnYxLkNoYW5uZWxHcm91cFZpc2liaWxpdHkijAMKB0NoYW5uZWwSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIQCghncm91cF9pZBgDIAEoCRIlCgRraW5kGAQgASgOMhcuY29tcGFzcy52MS5DaGFubmVsS2luZBIaChJtZW1iZXJfYWNjb3VudF9pZHMYBSADKAkSHgoWc3Vic2NyaWJlcl9hY2NvdW50X2lkcxgGIAMoCRIyCgtwb3N0X3BvbGljeRgHIAEoDjIdLmNvbXBhc3MudjEuQ2hhbm5lbFBvc3RQb2xpY3kSGAoQb3duZXJfYWNjb3VudF9pZBgIIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAkgASgIEi8KDnBpbm5lZF9lbnRyaWVzGAogAygLMhcuY29tcGFzcy52MS5QaW5uZWRFbnRyeRIXCg9wYXJlbnRfYWdlbnRfaWQYCyABKAkSOgoPbWVtYmVyc2hpcF9tb2RlGAwgASgOMiEuY29tcGFzcy52MS5DaGFubmVsTWVtYmVyc2hpcE1vZGUibAoLUGlubmVkRW50cnkSEgoKbWVzc2FnZV9pZBgBIAEoCRIQCghwb3NpdGlvbhgCIAEoBRIZChFwaW5uZWRfYXRfdW5peF9tcxgDIAEoAxIcChRwaW5uZWRfYnlfYWNjb3VudF9pZBgEIAEoCSI2Cg5BZ2VudFdvcmtzcGFjZRIKCgJpZBgBIAEoCRIYChBhZ2VudF9hY2NvdW50X2lkGAIgASgJIvIBCgVUb3BpYxIKCgJpZBgBIAEoCRISCgpjaGFubmVsX2lkGAIgASgJEgwKBG5hbWUYAyABKAkSGgoSY3JlYXRlZF9hdF91bml4X21zGAQgASgDEh0KFWNyZWF0ZWRfYnlfYWNjb3VudF9pZBgFIAEoCRIQCghhcmNoaXZlZBgGIAEoCBIaCg1tZXNzYWdlX2NvdW50GAcgASgESACIAQESJAoXbGFzdF9tZXNzYWdlX2F0X3VuaXhfbXMYCCABKANIAYgBAUIQCg5fbWVzc2FnZV9jb3VudEIaChhfbGFzdF9tZXNzYWdlX2F0X3VuaXhfbXMiwgEKB01lc3NhZ2USCgoCaWQYASABKAkSEAoIdG9waWNfaWQYAiABKAkSGQoRYXV0aG9yX2FjY291bnRfaWQYAyABKAkSEgoKYXRfdW5peF9tcxgEIAEoAxIoCgZibG9ja3MYBSADKAsyGC5jb21wYXNzLnYxLk1lc3NhZ2VCbG9jaxIVCg1hdXRob3JfaGFuZGxlGAYgASgJEhIKCmNoYW5uZWxfaWQYByABKAkSFQoNdHVybl9zZXF1ZW5jZRgIIAEoBCJ5CgxNZXNzYWdlQmxvY2sSDgoEdGV4dBgBIAEoCUgAEh4KA2FzaxgCIAEoCzIPLmNvbXBhc3MudjEuQXNrSAASMAoKYXNrX2Fuc3dlchgDIAEoCzIaLmNvbXBhc3MudjEuQXNrQW5zd2VyQmxvY2tIAEIHCgVibG9jayJICg5Bc2tBbnN3ZXJCbG9jaxIcCgNhc2sYASABKAsyDy5jb21wYXNzLnYxLkFzaxIYChBhc2tlcl9hY2NvdW50X2lkGAIgASgJImAKA0FzaxIOCgZhc2tfaWQYASABKAkSKgoJcXVlc3Rpb25zGAIgAygLMhcuY29tcGFzcy52MS5Bc2tRdWVzdGlvbhIQCghhbnN3ZXJlZBgDIAEoCBILCgNyZWYYBCABKAki8QEKC0Fza1F1ZXN0aW9uEhMKC3F1ZXN0aW9uX2lkGAEgASgJEhAKCHF1ZXN0aW9uGAIgASgJEg4KBmhlYWRlchgDIAEoCRImCgdvcHRpb25zGAQgAygLMhUuY29tcGFzcy52MS5Bc2tPcHRpb24SFgoOYWxsb3dfbXVsdGlwbGUYBSABKAgSGAoLcmVjb21tZW5kZWQYBiABKAVIAIgBARIZChFjaG9zZW5fb3B0aW9uX2lkcxgHIAMoCRITCgtjdXN0b21fdGV4dBgIIAEoCRIRCgl0aW1lZF9vdXQYCSABKAhCDgoMX3JlY29tbWVuZGVkIkwKCUFza09wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRIPCgdwcmV2aWV3GAQgASgJIscFChZTdWJzY3JpYmVDb21tc1Jlc3BvbnNlEgsKA3NlcRgBIAEoBBISCgphdF91bml4X21zGAIgASgDEhYKDmluc3RhbmNlX2Vwb2NoGAMgASgEEhQKDHNuYXBzaG90X3NlcRgEIAEoBBIzCg5tZXNzYWdlX3Bvc3RlZBgKIAEoCzIZLmNvbXBhc3MudjEuTWVzc2FnZVBvc3RlZEgAEjUKD21lc3NhZ2VfdXBkYXRlZBgLIAEoCzIaLmNvbXBhc3MudjEuTWVzc2FnZVVwZGF0ZWRIABI1Cg9jaGFubmVsX2NoYW5nZWQYDCABKAsyGi5jb21wYXNzLnYxLkNoYW5uZWxDaGFuZ2VkSAASQAoVY2hhbm5lbF9ncm91cF9jaGFuZ2VkGA0gASgLMh8uY29tcGFzcy52MS5DaGFubmVsR3JvdXBDaGFuZ2VkSAASNQoPYWNjb3VudF9jaGFuZ2VkGA4gASgLMhouY29tcGFzcy52MS5BY2NvdW50Q2hhbmdlZEgAEkQKF2FnZW50X3dvcmtzcGFjZV9jaGFuZ2VkGA8gASgLMiEuY29tcGFzcy52MS5BZ2VudFdvcmtzcGFjZUNoYW5nZWRIABI6Cg9yZXN5bmNfcmVxdWlyZWQYECABKAsyHy5jb21wYXNzLnYxLkNvbW1zUmVzeW5jUmVxdWlyZWRIABJCChZhZ2VudF9wcmVzZW5jZV9jaGFuZ2VkGBEgASgLMiAuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlQ2hhbmdlZEgAEjMKDnRvcGljX3Vwc2VydGVkGBIgASgLMhkuY29tcGFzcy52MS5Ub3BpY1Vwc2VydGVkSAASPAoTYWdlbnRfYm9hcmRfY2hhbmdlZBgTIAEoCzIdLmNvbXBhc3MudjEuQWdlbnRCb2FyZENoYW5nZWRIAEIJCgdwYXlsb2FkIkwKDU1lc3NhZ2VQb3N0ZWQSJAoHbWVzc2FnZRgBIAEoCzITLmNvbXBhc3MudjEuTWVzc2FnZRIVCg10dXJuX3NlcXVlbmNlGAIgASgEIjYKDk1lc3NhZ2VVcGRhdGVkEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiMQoNVG9waWNVcHNlcnRlZBIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiUwoOQ2hhbm5lbENoYW5nZWQSJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbBIbChNyZW1vdmVkX2FjY291bnRfaWRzGAIgAygJIj4KE0NoYW5uZWxHcm91cENoYW5nZWQSJwoFZ3JvdXAYASABKAsyGC5jb21wYXNzLnYxLkNoYW5uZWxHcm91cCI2Cg5BY2NvdW50Q2hhbmdlZBIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50IkYKFUFnZW50V29ya3NwYWNlQ2hhbmdlZBItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIhUKE0NvbW1zUmVzeW5jUmVxdWlyZWQibwoUQWdlbnRQcmVzZW5jZUNoYW5nZWQSGAoQYWdlbnRfYWNjb3VudF9pZBgBIAEoCRIrCghwcmVzZW5jZRgCIAEoDjIZLmNvbXBhc3MudjEuQWdlbnRQcmVzZW5jZRIQCghhY3Rpdml0eRgDIAEoCSJ3CgxCb2FyZFdhaXRpbmcSJgoCb24YASABKA4yGi5jb21wYXNzLnYxLkJvYXJkV2FpdGluZ09uEhQKDGFnZW50X2hhbmRsZRgCIAEoCRIMCgR3aGF0GAMgASgJEgsKA3JlZhgEIAEoCRIOCgZibG9ja3MYBSADKAkihQEKDUJvYXJkU3ViYWdlbnQSCgoCaWQYASABKAkSDQoFYWdlbnQYAiABKAkSEwoLZGVzY3JpcHRpb24YAyABKAkSDgoGc3RhdHVzGAQgASgJEhoKEnN0YXJ0ZWRfYXRfdW5peF9tcxgFIAEoAxIYChBlbmRlZF9hdF91bml4X21zGAYgASgDIvgBCglCb2FyZFRhc2sSCwoDa2V5GAEgASgJEg0KBXRpdGxlGAIgASgJEhMKC3ByaW1hcnlfcmVmGAMgASgJEgwKBHJlZnMYBCADKAkSKQoFc3RhdGUYBSABKA4yGi5jb21wYXNzLnYxLkJvYXJkVGFza1N0YXRlEgwKBG5vdGUYBiABKAkSKQoHd2FpdGluZxgHIAMoCzIYLmNvbXBhc3MudjEuQm9hcmRXYWl0aW5nEiwKCXN1YmFnZW50cxgIIAMoCzIZLmNvbXBhc3MudjEuQm9hcmRTdWJhZ2VudBIaChJ3cml0dGVuX2F0X3VuaXhfbXMYCSABKAMiHQoLQm9hcmRSZWZzT3ASDgoGdmFsdWVzGAEgAygJIjoKDkJvYXJkV2FpdGluZ09wEigKBnZhbHVlcxgBIAMoCzIYLmNvbXBhc3MudjEuQm9hcmRXYWl0aW5nIsQCCg5Cb2FyZFRhc2tQYXRjaBILCgNrZXkYASABKAkSEgoFdGl0bGUYAiABKAlIAIgBARIYCgtwcmltYXJ5X3JlZhgDIAEoCUgBiAEBEi4KBXN0YXRlGAQgASgOMhouY29tcGFzcy52MS5Cb2FyZFRhc2tTdGF0ZUgCiAEBEhEKBG5vdGUYBSABKAlIA4gBARIlCgRyZWZzGAYgASgLMhcuY29tcGFzcy52MS5Cb2FyZFJlZnNPcBIrCgd3YWl0aW5nGAcgASgLMhouY29tcGFzcy52MS5Cb2FyZFdhaXRpbmdPcBIzChB1cHNlcnRfc3ViYWdlbnRzGAggAygLMhkuY29tcGFzcy52MS5Cb2FyZFN1YmFnZW50QggKBl90aXRsZUIOCgxfcHJpbWFyeV9yZWZCCAoGX3N0YXRlQgcKBV9ub3RlIlEKCUJvYXJkSGFsdBInCgRraW5kGAEgASgOMhkuY29tcGFzcy52MS5Cb2FyZEhhbHRLaW5kEgsKA3JlZhgCIAEoCRIOCgZyZWFzb24YAyABKAkijwMKCEJvYXJkUmVmEgsKA3JlZhgBIAEoCRImCgRraW5kGAIgASgOMhguY29tcGFzcy52MS5Cb2FyZFJlZktpbmQSDQoFdGl0bGUYAyABKAkSCwoDdXJsGAQgASgJEhMKC2ZvcmdlX3N0YXRlGAUgASgJEisKC2lzc3VlX3N0YXRlGAYgASgOMhYuY29tcGFzcy52MS5Jc3N1ZVN0YXRlEg0KBWRyYWZ0GAcgASgIEhQKDGNoZWNrc19zdGF0ZRgIIAEoCRIlCgRnYXRlGAkgASgOMhcuY29tcGFzcy52MS5Cb2FyZFByR2F0ZRIVCg1zdGFja190aXBfcmVmGAogASgJEhYKDnN0YWNrX3Bvc2l0aW9uGAsgASgNEg0KBW93bmVkGAwgASgIEhsKE29ic2VydmVkX2F0X3VuaXhfbXMYDSABKAMSGQoRbWVyZ2VfcXVldWVfc3RhdGUYDiABKAkSFgoOc3RhbGVfYXBwcm92YWwYDyABKAgSFgoOdHJhY2tlcl9zdGF0dXMYECABKAki1AEKCEJvYXJkQXNrEg4KBmFza19pZBgBIAEoCRISCgptZXNzYWdlX2lkGAIgASgJEhIKCmNoYW5uZWxfaWQYAyABKAkSCwoDcmVmGAQgASgJEg4KBmhlYWRlchgFIAEoCRIQCghhbnN3ZXJlZBgGIAEoCBIZChFwb3N0ZWRfYXRfdW5peF9tcxgHIAEoAxIyCgxtaXJyb3Jfc3RhdGUYCCABKA4yHC5jb21wYXNzLnYxLkJvYXJkTWlycm9yU3RhdGUSEgoKbWlycm9yX3VybBgJIAEoCSJuChRNaXJyb3JUb0ZvcmdlUmVxdWVzdBILCgNyZWYYASABKAkSDgoGYXNrX2lkGAIgASgJEhAKCHRvcGljX2lkGAMgASgJEhMKC21lc3NhZ2VfaWRzGAQgAygJEhIKCmNoYW5uZWxfaWQYBSABKAkiLAoVTWlycm9yVG9Gb3JnZVJlc3BvbnNlEhMKC2NvbW1lbnRfdXJsGAEgASgJItUCCgpBZ2VudEJvYXJkEhgKEGFnZW50X2FjY291bnRfaWQYASABKAkSDgoGaGFuZGxlGAIgASgJEhYKDmZvY3VzX3Rhc2tfa2V5GAMgASgJEiMKBGhhbHQYBCABKAsyFS5jb21wYXNzLnYxLkJvYXJkSGFsdBIkCgV0YXNrcxgFIAMoCzIVLmNvbXBhc3MudjEuQm9hcmRUYXNrEiIKBHJlZnMYBiADKAsyFC5jb21wYXNzLnYxLkJvYXJkUmVmEhYKDnVudHJhY2tlZF9yZWZzGAcgAygJEiIKBGFza3MYCCADKAsyFC5jb21wYXNzLnYxLkJvYXJkQXNrEhQKDHdyaXR0ZW5fdHVybhgJIAEoBBIaChJ3cml0dGVuX2F0X3VuaXhfbXMYCiABKAMSDQoFc3RhbGUYCyABKAgSGQoRbWF0Y2hlZF90YXNrX2tleXMYDCADKAkiowEKEUFnZW50Qm9hcmRTdW1tYXJ5Eg0KBWZvY3VzGAEgASgJEhIKCnRhc2tfY291bnQYAiABKA0SGwoTd2FpdGluZ19vbl9vcGVyYXRvchgDIAEoDRIjCgRoYWx0GAQgASgLMhUuY29tcGFzcy52MS5Cb2FyZEhhbHQSGgoSd3JpdHRlbl9hdF91bml4X21zGAUgASgDEg0KBXN0YWxlGAYgASgIIvgBChdVcGRhdGVBZ2VudEJvYXJkUmVxdWVzdBIwCgx1cHNlcnRfdGFza3MYASADKAsyGi5jb21wYXNzLnYxLkJvYXJkVGFza1BhdGNoEhgKEHJlbW92ZV90YXNrX2tleXMYAiADKAkSGwoOZm9jdXNfdGFza19rZXkYAyABKAlIAIgBARIjCgRoYWx0GAQgASgLMhUuY29tcGFzcy52MS5Cb2FyZEhhbHQSEgoKY2xlYXJfaGFsdBgFIAEoCBIVCg10dXJuX3NlcXVlbmNlGAYgASgEEhEKCWF1dG9tYXRpYxgHIAEoCEIRCg9fZm9jdXNfdGFza19rZXkiQQoYVXBkYXRlQWdlbnRCb2FyZFJlc3BvbnNlEiUKBWJvYXJkGAEgASgLMhYuY29tcGFzcy52MS5BZ2VudEJvYXJkIt4BChZMaXN0QWdlbnRCb2FyZHNSZXF1ZXN0EhUKDWFnZW50X2hhbmRsZXMYASADKAkSDQoFcXVlcnkYAiABKAkSCwoDcmVmGAMgASgJEi4KCndhaXRpbmdfb24YBCABKA4yGi5jb21wYXNzLnYxLkJvYXJkV2FpdGluZ09uEikKBXN0YXRlGAUgASgOMhouY29tcGFzcy52MS5Cb2FyZFRhc2tTdGF0ZRInCgRoYWx0GAYgASgOMhkuY29tcGFzcy52MS5Cb2FyZEhhbHRLaW5kEg0KBWxpbWl0GAcgASgNIkEKF0xpc3RBZ2VudEJvYXJkc1Jlc3BvbnNlEiYKBmJvYXJkcxgBIAMoCzIWLmNvbXBhc3MudjEuQWdlbnRCb2FyZCJUChFBZ2VudEJvYXJkQ2hhbmdlZBIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEiUKBWJvYXJkGAIgASgLMhYuY29tcGFzcy52MS5BZ2VudEJvYXJkIlsKB1BlZXJpbmcSFwoPdXNlcl9hY2NvdW50X2lkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRInCgVzdGF0ZRgDIAEoDjIYLmNvbXBhc3MudjEuUGVlcmluZ1N0YXRlIikKEkFwcHJvdmVQZWVyUmVxdWVzdBITCgtwZWVyX2hhbmRsZRgBIAEoCSI7ChNBcHByb3ZlUGVlclJlc3BvbnNlEiQKB3BlZXJpbmcYASABKAsyEy5jb21wYXNzLnYxLlBlZXJpbmciKAoRUmV2b2tlUGVlclJlcXVlc3QSEwoLcGVlcl9oYW5kbGUYASABKAkiJQoSUmV2b2tlUGVlclJlc3BvbnNlEg8KB2RlbGV0ZWQYASABKAgiEgoQTGlzdFBlZXJzUmVxdWVzdCI6ChFMaXN0UGVlcnNSZXNwb25zZRIlCghwZWVyaW5ncxgBIAMoCzITLmNvbXBhc3MudjEuUGVlcmluZyI5ChFDcmVhdGVVc2VyUmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJIjoKEkNyZWF0ZVVzZXJSZXNwb25zZRIkCgdhY2NvdW50GAEgASgLMhMuY29tcGFzcy52MS5BY2NvdW50InAKEkNyZWF0ZUFnZW50UmVxdWVzdBIOCgZoYW5kbGUYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEhUKDXBhcmVudF9oYW5kbGUYAyABKAkSDAoEcm9sZRgEIAEoCRIPCgdwZXJzb25hGAUgASgJIjsKE0NyZWF0ZUFnZW50UmVzcG9uc2USJAoHYWNjb3VudBgBIAEoCzITLmNvbXBhc3MudjEuQWNjb3VudCIrChNMaXN0QWNjb3VudHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCI9ChRMaXN0QWNjb3VudHNSZXNwb25zZRIlCghhY2NvdW50cxgBIAMoCzITLmNvbXBhc3MudjEuQWNjb3VudCKVAQoZQ3JlYXRlQ2hhbm5lbEdyb3VwUmVxdWVzdBIMCgRuYW1lGAEgASgJEhcKD3BhcmVudF9ncm91cF9pZBgCIAEoCRI2Cgp2aXNpYmlsaXR5GAMgASgOMiIuY29tcGFzcy52MS5DaGFubmVsR3JvdXBWaXNpYmlsaXR5EhkKEXBhcmVudF9ncm91cF9uYW1lGAQgASgJIkUKGkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEicKBWdyb3VwGAEgASgLMhguY29tcGFzcy52MS5DaGFubmVsR3JvdXAiMAoYTGlzdENoYW5uZWxHcm91cHNSZXF1ZXN0EhQKDHNuYXBzaG90X3NlcRgBIAEoBCJFChlMaXN0Q2hhbm5lbEdyb3Vwc1Jlc3BvbnNlEigKBmdyb3VwcxgBIAMoCzIYLmNvbXBhc3MudjEuQ2hhbm5lbEdyb3VwIisKE0xpc3RDaGFubmVsc1JlcXVlc3QSFAoMc25hcHNob3Rfc2VxGAEgASgEIj0KFExpc3RDaGFubmVsc1Jlc3BvbnNlEiUKCGNoYW5uZWxzGAEgAygLMhMuY29tcGFzcy52MS5DaGFubmVsIuIBChRDcmVhdGVDaGFubmVsUmVxdWVzdBIMCgRuYW1lGAEgASgJEhAKCGdyb3VwX2lkGAIgASgJEiUKBGtpbmQYAyABKA4yFy5jb21wYXNzLnYxLkNoYW5uZWxLaW5kEhYKDm1lbWJlcl9oYW5kbGVzGAQgAygJEhsKE3BhcmVudF9hZ2VudF9oYW5kbGUYBSABKAkSOgoPbWVtYmVyc2hpcF9tb2RlGAYgASgOMiEuY29tcGFzcy52MS5DaGFubmVsTWVtYmVyc2hpcE1vZGUSEgoKZ3JvdXBfbmFtZRgHIAEoCSI9ChVDcmVhdGVDaGFubmVsUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCLCAQobVXBkYXRlQ2hhbm5lbE1lbWJlcnNSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSGgoSYWRkX21lbWJlcl9oYW5kbGVzGAIgAygJEh0KFXJlbW92ZV9tZW1iZXJfaGFuZGxlcxgDIAMoCRIZChFzdWJzY3JpYmVfaGFuZGxlcxgEIAMoCRIbChN1bnN1YnNjcmliZV9oYW5kbGVzGAUgAygJEhwKFGNvbnZlcnRfY2hhbm5lbF9uYW1lGAYgASgJIkQKHFVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIkCg1PcGVuRE1SZXF1ZXN0EhMKC3BlZXJfaGFuZGxlGAEgASgJIkcKDk9wZW5ETVJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwSDwoHY3JlYXRlZBgCIAEoCCJHChRSZXBhcmVudEFnZW50UmVxdWVzdBIUCgxhZ2VudF9oYW5kbGUYASABKAkSGQoRbmV3X3BhcmVudF9oYW5kbGUYAiABKAkiPQoVUmVwYXJlbnRBZ2VudFJlc3BvbnNlEiQKB2FjY291bnQYASABKAsyEy5jb21wYXNzLnYxLkFjY291bnQiTQoWUmVwYXJlbnRDaGFubmVsUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEh8KF25ld19wYXJlbnRfYWdlbnRfaGFuZGxlGAIgASgJIj8KF1JlcGFyZW50Q2hhbm5lbFJlc3BvbnNlEiQKB2NoYW5uZWwYASABKAsyEy5jb21wYXNzLnYxLkNoYW5uZWwilwEKF1NldENoYW5uZWxQb2xpY3lSZXF1ZXN0EhIKCmNoYW5uZWxfaWQYASABKAkSMgoLcG9zdF9wb2xpY3kYAiABKA4yHS5jb21wYXNzLnYxLkNoYW5uZWxQb3N0UG9saWN5EhQKDG93bmVyX2hhbmRsZRgDIAEoCRIeChZtYW5kYXRvcnlfc3Vic2NyaXB0aW9uGAQgASgIIkAKGFNldENoYW5uZWxQb2xpY3lSZXNwb25zZRIkCgdjaGFubmVsGAEgASgLMhMuY29tcGFzcy52MS5DaGFubmVsIlIKEEdldFJvc3RlclJlcXVlc3QSJgoFc2NvcGUYASABKA4yFy5jb21wYXNzLnYxLlJvc3RlclNjb3BlEhYKDnZhbnRhZ2VfaGFuZGxlGAIgASgJIj0KEUdldFJvc3RlclJlc3BvbnNlEigKB2VudHJpZXMYASADKAsyFy5jb21wYXNzLnYxLlJvc3RlckVudHJ5IvABCgtSb3N0ZXJFbnRyeRIYChBhZ2VudF9hY2NvdW50X2lkGAEgASgJEg4KBmhhbmRsZRgCIAEoCRIUCgxkaXNwbGF5X25hbWUYAyABKAkSFwoPcGFyZW50X2FnZW50X2lkGAQgASgJEisKCHByZXNlbmNlGAUgASgOMhkuY29tcGFzcy52MS5BZ2VudFByZXNlbmNlEhAKCGFjdGl2aXR5GAYgASgJEhsKE2FjdGl2aXR5X2F0X3VuaXhfbXMYByABKAMSLAoFYm9hcmQYCCABKAsyHS5jb21wYXNzLnYxLkFnZW50Qm9hcmRTdW1tYXJ5IncKGFVwZGF0ZVBpbm5lZEJvYXJkUmVxdWVzdBISCgpjaGFubmVsX2lkGAEgASgJEiUKA3BpbhgCIAEoCzIWLmNvbXBhc3MudjEuUGluTWVzc2FnZUgAEhoKEHVucGluX21lc3NhZ2VfaWQYAyABKAlIAEIECgJvcCI8CgpQaW5NZXNzYWdlEhIKCm1lc3NhZ2VfaWQYASABKAkSGgoScmVwbGFjZV9tZXNzYWdlX2lkGAIgASgJIkEKGVVwZGF0ZVBpbm5lZEJvYXJkUmVzcG9uc2USJAoHY2hhbm5lbBgBIAEoCzITLmNvbXBhc3MudjEuQ2hhbm5lbCIxChlPcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0EhQKDGFnZW50X2hhbmRsZRgBIAEoCSJLChpPcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRItCgl3b3Jrc3BhY2UYASABKAsyGi5jb21wYXNzLnYxLkFnZW50V29ya3NwYWNlIooBChNMaXN0TWVzc2FnZXNSZXF1ZXN0EhQKCmNoYW5uZWxfaWQYASABKAlIABINCgVsaW1pdBgCIAEoDRIZChFiZWZvcmVfbWVzc2FnZV9pZBgDIAEoCRIUCgxzbmFwc2hvdF9zZXEYBCABKAQSEAoIdG9waWNfaWQYBSABKAlCCwoJY29udGFpbmVyIj0KFExpc3RNZXNzYWdlc1Jlc3BvbnNlEiUKCG1lc3NhZ2VzGAEgAygLMhMuY29tcGFzcy52MS5NZXNzYWdlItwBChJQb3N0TWVzc2FnZVJlcXVlc3QSFAoKY2hhbm5lbF9pZBgBIAEoCUgAEigKBmJsb2NrcxgCIAMoCzIYLmNvbXBhc3MudjEuTWVzc2FnZUJsb2NrEhIKCHRvcGljX2lkGAMgASgJSAESFAoKdG9waWNfbmFtZRgEIAEoCUgBEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAUgASgJEhQKDGNyZWF0ZV90b3BpYxgGIAEoCBIVCg10dXJuX3NlcXVlbmNlGAcgASgEQgsKCWNvbnRhaW5lckIHCgV0b3BpYyI7ChNQb3N0TWVzc2FnZVJlc3BvbnNlEiQKB21lc3NhZ2UYASABKAsyEy5jb21wYXNzLnYxLk1lc3NhZ2UiQQoRTGlzdFRvcGljc1JlcXVlc3QSEgoKY2hhbm5lbF9pZBgBIAEoCRIYChBpbmNsdWRlX2FyY2hpdmVkGAIgASgIIjcKEkxpc3RUb3BpY3NSZXNwb25zZRIhCgZ0b3BpY3MYASADKAsyES5jb21wYXNzLnYxLlRvcGljImYKElVwZGF0ZVRvcGljUmVxdWVzdBIQCgh0b3BpY19pZBgBIAEoCRIRCgRuYW1lGAIgASgJSACIAQESFQoIYXJjaGl2ZWQYAyABKAhIAYgBAUIHCgVfbmFtZUILCglfYXJjaGl2ZWQiNwoTVXBkYXRlVG9waWNSZXNwb25zZRIgCgV0b3BpYxgBIAEoCzIRLmNvbXBhc3MudjEuVG9waWMiVQoTUmVzcG9uZFRvQXNrUmVxdWVzdBIOCgZhc2tfaWQYASABKAkSLgoHYW5zd2VycxgCIAMoCzIdLmNvbXBhc3MudjEuQXNrUXVlc3Rpb25BbnN3ZXIiWAoRQXNrUXVlc3Rpb25BbnN3ZXISEwoLcXVlc3Rpb25faWQYASABKAkSGQoRY2hvc2VuX29wdGlvbl9pZHMYAiADKAkSEwoLY3VzdG9tX3RleHQYAyABKAkiFgoUUmVzcG9uZFRvQXNrUmVzcG9uc2UiagoVU2VhcmNoTWVzc2FnZXNSZXF1ZXN0Eg0KBXF1ZXJ5GAEgASgJEhQKCmNoYW5uZWxfaWQYAiABKAlIABINCgVsaW1pdBgDIAEoDRIUCgxzbmFwc2hvdF9zZXEYBCABKARCBwoFc2NvcGUiPwoWU2VhcmNoTWVzc2FnZXNSZXNwb25zZRIlCghtZXNzYWdlcxgBIAMoCzITLmNvbXBhc3MudjEuTWVzc2FnZSJCChVTdWJzY3JpYmVDb21tc1JlcXVlc3QSEQoJc2luY2Vfc2VxGAEgASgEEhYKDmluc3RhbmNlX2Vwb2NoGAIgASgEKjUKCFVzZXJSb2xlEhQKEFVTRVJfUk9MRV9NRU1CRVIQABITCg9VU0VSX1JPTEVfQURNSU4QASphChZDaGFubmVsR3JvdXBWaXNpYmlsaXR5EiIKHkNIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9PV05FUhAAEiMKH0NIQU5ORUxfR1JPVVBfVklTSUJJTElUWV9TSEFSRUQQASpVChFDaGFubmVsUG9zdFBvbGljeRIcChhDSEFOTkVMX1BPU1RfUE9MSUNZX09QRU4QABIiCh5DSEFOTkVMX1BPU1RfUE9MSUNZX09XTkVSX09OTFkQASpfChVDaGFubmVsTWVtYmVyc2hpcE1vZGUSJAogQ0hBTk5FTF9NRU1CRVJTSElQX01PREVfRVhQTElDSVQQABIgChxDSEFOTkVMX01FTUJFUlNISVBfTU9ERV9UUkVFEAEqWwoLQ2hhbm5lbEtpbmQSGAoUQ0hBTk5FTF9LSU5EX0NIQU5ORUwQABITCg9DSEFOTkVMX0tJTkRfRE0QARIdChVDSEFOTkVMX0tJTkRfR1JPVVBfRE0QAhoCCAEqnAEKDUFnZW50UHJlc2VuY2USHgoaQUdFTlRfUFJFU0VOQ0VfVU5TUEVDSUZJRUQQABIXChNBR0VOVF9QUkVTRU5DRV9JRExFEAESGgoWQUdFTlRfUFJFU0VOQ0VfV09SS0lORxACEhoKFkFHRU5UX1BSRVNFTkNFX1dBSVRJTkcQAxIaChZBR0VOVF9QUkVTRU5DRV9PRkZMSU5FEAQqwwEKDkJvYXJkVGFza1N0YXRlEiAKHEJPQVJEX1RBU0tfU1RBVEVfVU5TUEVDSUZJRUQQABIbChdCT0FSRF9UQVNLX1NUQVRFX0FDVElWRRABEhwKGEJPQVJEX1RBU0tfU1RBVEVfQkxPQ0tFRBACEhwKGEJPQVJEX1RBU0tfU1RBVEVfV0FJVElORxADEhsKF0JPQVJEX1RBU0tfU1RBVEVfUEFSS0VEEAQSGQoVQk9BUkRfVEFTS19TVEFURV9ET05FEAUqigEKDUJvYXJkSGFsdEtpbmQSHwobQk9BUkRfSEFMVF9LSU5EX1VOU1BFQ0lGSUVEEAASGAoUQk9BUkRfSEFMVF9LSU5EX0RPTkUQARIbChdCT0FSRF9IQUxUX0tJTkRfQkxPQ0tFRBACEiEKHUJPQVJEX0hBTFRfS0lORF9BV0FJVElOR19VU0VSEAMqjAEKDkJvYXJkV2FpdGluZ09uEiAKHEJPQVJEX1dBSVRJTkdfT05fVU5TUEVDSUZJRUQQABIdChlCT0FSRF9XQUlUSU5HX09OX09QRVJBVE9SEAESGgoWQk9BUkRfV0FJVElOR19PTl9BR0VOVBACEh0KGUJPQVJEX1dBSVRJTkdfT05fRVhURVJOQUwQAyppCgxCb2FyZFJlZktpbmQSHgoaQk9BUkRfUkVGX0tJTkRfVU5TUEVDSUZJRUQQABIYChRCT0FSRF9SRUZfS0lORF9JU1NVRRABEh8KG0JPQVJEX1JFRl9LSU5EX1BVTExfUkVRVUVTVBACKqADCgtCb2FyZFByR2F0ZRIdChlCT0FSRF9QUl9HQVRFX1VOU1BFQ0lGSUVEEAASFwoTQk9BUkRfUFJfR0FURV9EUkFGVBABEiAKHEJPQVJEX1BSX0dBVEVfQ0hFQ0tTX1BFTkRJTkcQAhIfChtCT0FSRF9QUl9HQVRFX0NIRUNLU19GQUlMRUQQAxIjCh9CT0FSRF9QUl9HQVRFX0NIQU5HRVNfUkVRVUVTVEVEEAQSIQodQk9BUkRfUFJfR0FURV9BV0FJVElOR19SRVZJRVcQBRIiCh5CT0FSRF9QUl9HQVRFX0FXQUlUSU5HX0VOUVVFVUUQBhIYChRCT0FSRF9QUl9HQVRFX01FUkdFRBAHEhgKFEJPQVJEX1BSX0dBVEVfQ0xPU0VEEAgSGAoUQk9BUkRfUFJfR0FURV9RVUVVRUQQCRIeChpCT0FSRF9QUl9HQVRFX1FVRVVFX0ZBSUxFRBAKEh8KG0JPQVJEX1BSX0dBVEVfUVVFVUVfUkVNT1ZFRBALEhsKF0JPQVJEX1BSX0dBVEVfQ09ORkxJQ1RTEAwqkgEKEEJvYXJkTWlycm9yU3RhdGUSIgoeQk9BUkRfTUlSUk9SX1NUQVRFX1VOU1BFQ0lGSUVEEAASHgoaQk9BUkRfTUlSUk9SX1NUQVRFX1BFTkRJTkcQARIbChdCT0FSRF9NSVJST1JfU1RBVEVfRE9ORRACEh0KGUJPQVJEX01JUlJPUl9TVEFURV9GQUlMRUQQAyqRAQoMUGVlcmluZ1N0YXRlEh0KGVBFRVJJTkdfU1RBVEVfVU5TUEVDSUZJRUQQABIiCh5QRUVSSU5HX1NUQVRFX1BFTkRJTkdfT1VUR09JTkcQARIiCh5QRUVSSU5HX1NUQVRFX1BFTkRJTkdfSU5DT01JTkcQAhIaChZQRUVSSU5HX1NUQVRFX0FQUFJPVkVEEAMqXgoLUm9zdGVyU2NvcGUSHQoZUk9TVEVSX1NDT1BFX05FSUdIQk9SSE9PRBAAEhgKFFJPU1RFUl9TQ09QRV9TVUJUUkVFEAESFgoSUk9TVEVSX1NDT1BFX09XTkVSEAIynBIKDENvbW1zU2VydmljZRJLCgpDcmVhdGVVc2VyEh0uY29tcGFzcy52MS5DcmVhdGVVc2VyUmVxdWVzdBoeLmNvbXBhc3MudjEuQ3JlYXRlVXNlclJlc3BvbnNlEk4KC0NyZWF0ZUFnZW50Eh4uY29tcGFzcy52MS5DcmVhdGVBZ2VudFJlcXVlc3QaHy5jb21wYXNzLnYxLkNyZWF0ZUFnZW50UmVzcG9uc2USUQoMTGlzdEFjY291bnRzEh8uY29tcGFzcy52MS5MaXN0QWNjb3VudHNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0QWNjb3VudHNSZXNwb25zZRJjChJDcmVhdGVDaGFubmVsR3JvdXASJS5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlcXVlc3QaJi5jb21wYXNzLnYxLkNyZWF0ZUNoYW5uZWxHcm91cFJlc3BvbnNlEmAKEUxpc3RDaGFubmVsR3JvdXBzEiQuY29tcGFzcy52MS5MaXN0Q2hhbm5lbEdyb3Vwc1JlcXVlc3QaJS5jb21wYXNzLnYxLkxpc3RDaGFubmVsR3JvdXBzUmVzcG9uc2USUQoMTGlzdENoYW5uZWxzEh8uY29tcGFzcy52MS5MaXN0Q2hhbm5lbHNSZXF1ZXN0GiAuY29tcGFzcy52MS5MaXN0Q2hhbm5lbHNSZXNwb25zZRJUCg1DcmVhdGVDaGFubmVsEiAuY29tcGFzcy52MS5DcmVhdGVDaGFubmVsUmVxdWVzdBohLmNvbXBhc3MudjEuQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEmkKFFVwZGF0ZUNoYW5uZWxNZW1iZXJzEicuY29tcGFzcy52MS5VcGRhdGVDaGFubmVsTWVtYmVyc1JlcXVlc3QaKC5jb21wYXNzLnYxLlVwZGF0ZUNoYW5uZWxNZW1iZXJzUmVzcG9uc2USPwoGT3BlbkRNEhkuY29tcGFzcy52MS5PcGVuRE1SZXF1ZXN0GhouY29tcGFzcy52MS5PcGVuRE1SZXNwb25zZRJUCg1SZXBhcmVudEFnZW50EiAuY29tcGFzcy52MS5SZXBhcmVudEFnZW50UmVxdWVzdBohLmNvbXBhc3MudjEuUmVwYXJlbnRBZ2VudFJlc3BvbnNlEloKD1JlcGFyZW50Q2hhbm5lbBIiLmNvbXBhc3MudjEuUmVwYXJlbnRDaGFubmVsUmVxdWVzdBojLmNvbXBhc3MudjEuUmVwYXJlbnRDaGFubmVsUmVzcG9uc2USYwoST3BlbkFnZW50V29ya3NwYWNlEiUuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXF1ZXN0GiYuY29tcGFzcy52MS5PcGVuQWdlbnRXb3Jrc3BhY2VSZXNwb25zZRJRCgxMaXN0TWVzc2FnZXMSHy5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1JlcXVlc3QaIC5jb21wYXNzLnYxLkxpc3RNZXNzYWdlc1Jlc3BvbnNlEk4KC1Bvc3RNZXNzYWdlEh4uY29tcGFzcy52MS5Qb3N0TWVzc2FnZVJlcXVlc3QaHy5jb21wYXNzLnYxLlBvc3RNZXNzYWdlUmVzcG9uc2USSwoKTGlzdFRvcGljcxIdLmNvbXBhc3MudjEuTGlzdFRvcGljc1JlcXVlc3QaHi5jb21wYXNzLnYxLkxpc3RUb3BpY3NSZXNwb25zZRJOCgtVcGRhdGVUb3BpYxIeLmNvbXBhc3MudjEuVXBkYXRlVG9waWNSZXF1ZXN0Gh8uY29tcGFzcy52MS5VcGRhdGVUb3BpY1Jlc3BvbnNlElEKDFJlc3BvbmRUb0FzaxIfLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVxdWVzdBogLmNvbXBhc3MudjEuUmVzcG9uZFRvQXNrUmVzcG9uc2USVAoNTWlycm9yVG9Gb3JnZRIgLmNvbXBhc3MudjEuTWlycm9yVG9Gb3JnZVJlcXVlc3QaIS5jb21wYXNzLnYxLk1pcnJvclRvRm9yZ2VSZXNwb25zZRJXCg5TZWFyY2hNZXNzYWdlcxIhLmNvbXBhc3MudjEuU2VhcmNoTWVzc2FnZXNSZXF1ZXN0GiIuY29tcGFzcy52MS5TZWFyY2hNZXNzYWdlc1Jlc3BvbnNlEl0KEFNldENoYW5uZWxQb2xpY3kSIy5jb21wYXNzLnYxLlNldENoYW5uZWxQb2xpY3lSZXF1ZXN0GiQuY29tcGFzcy52MS5TZXRDaGFubmVsUG9saWN5UmVzcG9uc2USSAoJR2V0Um9zdGVyEhwuY29tcGFzcy52MS5HZXRSb3N0ZXJSZXF1ZXN0Gh0uY29tcGFzcy52MS5HZXRSb3N0ZXJSZXNwb25zZRJgChFVcGRhdGVQaW5uZWRCb2FyZBIkLmNvbXBhc3MudjEuVXBkYXRlUGlubmVkQm9hcmRSZXF1ZXN0GiUuY29tcGFzcy52MS5VcGRhdGVQaW5uZWRCb2FyZFJlc3BvbnNlEloKD0xpc3RBZ2VudEJvYXJkcxIiLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVxdWVzdBojLmNvbXBhc3MudjEuTGlzdEFnZW50Qm9hcmRzUmVzcG9uc2USTgoLQXBwcm92ZVBlZXISHi5jb21wYXNzLnYxLkFwcHJvdmVQZWVyUmVxdWVzdBofLmNvbXBhc3MudjEuQXBwcm92ZVBlZXJSZXNwb25zZRJLCgpSZXZva2VQZWVyEh0uY29tcGFzcy52MS5SZXZva2VQZWVyUmVxdWVzdBoeLmNvbXBhc3MudjEuUmV2b2tlUGVlclJlc3BvbnNlEkgKCUxpc3RQZWVycxIcLmNvbXBhc3MudjEuTGlzdFBlZXJzUmVxdWVzdBodLmNvbXBhc3MudjEuTGlzdFBlZXJzUmVzcG9uc2USWQoOU3Vic2NyaWJlQ29tbXMSIS5jb21wYXNzLnYxLlN1YnNjcmliZUNvbW1zUmVxdWVzdBoiLmNvbXBhc3MudjEuU3Vic2NyaWJlQ29tbXNSZXNwb25zZTABYgZwcm90bzM", [file_compass_v1_compass]); /** * An account in the communication layer: a human user or an owned agent. Both @@ -1216,7 +1216,7 @@ export type BoardWaiting = Message$1<"compass.v1.BoardWaiting"> & { ref: string; /** - * Task keys blocked by this wait. + * References blocked by this wait. * * @generated from field: repeated string blocks = 5; */ @@ -2035,7 +2035,7 @@ export type ListAgentBoardsRequest = Message$1<"compass.v1.ListAgentBoardsReques agentHandles: string[]; /** - * Searches board content for this query. + * Searches task titles and notes for this query. * * @generated from field: string query = 2; */ @@ -2106,7 +2106,8 @@ export const ListAgentBoardsResponseSchema: GenMessage messageDesc(file_compass_v1_comms, 41); /** - * A live agent-board update; the event's board has no asks. + * An authored-only board update. Clients re-read ListAgentBoards for derived refs + * and asks. * * @generated from message compass.v1.AgentBoardChanged */ @@ -2119,7 +2120,7 @@ export type AgentBoardChanged = Message$1<"compass.v1.AgentBoardChanged"> & { agentAccountId: string; /** - * The updated board with asks omitted. + * The authored board; clients re-read ListAgentBoards for derived refs and asks. * * @generated from field: compass.v1.AgentBoard board = 2; */ @@ -4390,6 +4391,16 @@ export const CommsService: GenService<{ input: typeof UpdatePinnedBoardRequestSchema; output: typeof UpdatePinnedBoardResponseSchema; }, + /** + * List whole boards for agents the caller can see, optionally filtered. + * + * @generated from rpc compass.v1.CommsService.ListAgentBoards + */ + listAgentBoards: { + methodKind: "unary"; + input: typeof ListAgentBoardsRequestSchema; + output: typeof ListAgentBoardsResponseSchema; + }, /** * Approve a user as a peer of the caller (a user account). A peering is * live only when both users have approved each other; until then this is @@ -4426,10 +4437,9 @@ export const CommsService: GenService<{ output: typeof ListPeersResponseSchema; }, /** - * The event stream: message, channel, group, account, and workspace updates, - * scoped to the caller's visible set, with gap-free replay-on-join. The sole - * push path for the comms surface, mirroring CompassService.SubscribeEvents' - * seq/epoch replay contract (D1). + * The comms event stream, scoped to the caller's visible set, with gap-free + * replay-on-join. The sole push path for the comms surface, mirroring + * CompassService.SubscribeEvents' seq/epoch replay contract (D1). * * @generated from rpc compass.v1.CommsService.SubscribeComms */ diff --git a/proto/compass/v1/agent_gateway.proto b/proto/compass/v1/agent_gateway.proto index be67fbcb2..b11d7827f 100644 --- a/proto/compass/v1/agent_gateway.proto +++ b/proto/compass/v1/agent_gateway.proto @@ -133,6 +133,8 @@ message CommsCallRequest { // into an org-management slot). OpenDMRequest open_dm = 11; ListTopicsRequest list_topics = 12; + UpdateAgentBoardRequest update_board = 13; + ListAgentBoardsRequest boards = 14; } // The W3C `traceparent` of the delivered message that triggered this outbound // call, re-attached by the agent so the Server can LINK the reply's fresh @@ -150,8 +152,9 @@ message CommsCallRequest { // The result of one comms call, correlated by `call_id`. A successful call sets // the response arm matching the request's operation (`post`, `list`, `roster`, // `set_status`, `pin`, `create_channel`, `update_members`, -// `create_channel_group`, `open_dm`, `list_topics`); an in-band failure (a tool error — non-member -// channel, bad input) sets `error`, which is NOT a transport teardown. The same +// `create_channel_group`, `open_dm`, `list_topics`, `update_board`, `boards`); +// an in-band failure (a tool error — non-member channel, bad input) sets +// `error`, which is NOT a transport teardown. The same // message is the `RelayCommsCallResponse.result` payload on the Runner->Server // leg. message CommsCallResult { @@ -168,6 +171,8 @@ message CommsCallResult { CreateChannelGroupResponse create_channel_group = 10; OpenDMResponse open_dm = 11; ListTopicsResponse list_topics = 12; + UpdateAgentBoardResponse update_board = 13; + ListAgentBoardsResponse boards = 14; } } diff --git a/proto/compass/v1/comms.proto b/proto/compass/v1/comms.proto index 53510df27..950510c49 100644 --- a/proto/compass/v1/comms.proto +++ b/proto/compass/v1/comms.proto @@ -135,6 +135,8 @@ service CommsService { // minting; the message must already live in a topic of the channel). A pin // may compare-and-swap a currently pinned entry. Emits ChannelChanged. rpc UpdatePinnedBoard(UpdatePinnedBoardRequest) returns (UpdatePinnedBoardResponse); + // List whole boards for agents the caller can see, optionally filtered. + rpc ListAgentBoards(ListAgentBoardsRequest) returns (ListAgentBoardsResponse); // Approve a user as a peer of the caller (a user account). A peering is // live only when both users have approved each other; until then this is @@ -147,10 +149,9 @@ service CommsService { rpc RevokePeer(RevokePeerRequest) returns (RevokePeerResponse); // List the caller's peerings: every user with an approval in either direction. rpc ListPeers(ListPeersRequest) returns (ListPeersResponse); - // The event stream: message, channel, group, account, and workspace updates, - // scoped to the caller's visible set, with gap-free replay-on-join. The sole - // push path for the comms surface, mirroring CompassService.SubscribeEvents' - // seq/epoch replay contract (D1). + // The comms event stream, scoped to the caller's visible set, with gap-free + // replay-on-join. The sole push path for the comms surface, mirroring + // CompassService.SubscribeEvents' seq/epoch replay contract (D1). rpc SubscribeComms(SubscribeCommsRequest) returns (stream SubscribeCommsResponse); } @@ -727,7 +728,7 @@ message BoardWaiting { string what = 3; // The related issue or pull request reference. string ref = 4; - // Task keys blocked by this wait. + // References blocked by this wait. repeated string blocks = 5; } @@ -973,7 +974,7 @@ message UpdateAgentBoardResponse { message ListAgentBoardsRequest { // Restricts results to these agent handles when non-empty. repeated string agent_handles = 1; - // Searches board content for this query. + // Searches task titles and notes for this query. string query = 2; // Restricts results to boards containing this reference. string ref = 3; @@ -993,11 +994,12 @@ message ListAgentBoardsResponse { repeated AgentBoard boards = 1; } -// A live agent-board update; the event's board has no asks. +// An authored-only board update. Clients re-read ListAgentBoards for derived refs +// and asks. message AgentBoardChanged { // The account identifier of the agent whose board changed. string agent_account_id = 1; - // The updated board with asks omitted. + // The authored board; clients re-read ListAgentBoards for derived refs and asks. AgentBoard board = 2; } From 293831ebf0c114bbc7a0ecb8879f97c758a77c55 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 13 Oct 2026 09:39:52 -0400 Subject: [PATCH 2/3] docs(spec): title cannot be cleared by an empty board patch (RIG-5172) Co-authored-by: Matt Wilkinson --- docs/specs/product/compass.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/specs/product/compass.md b/docs/specs/product/compass.md index 95cedd412..9e3c5bfdb 100644 --- a/docs/specs/product/compass.md +++ b/docs/specs/product/compass.md @@ -712,9 +712,10 @@ agent-gateway `update_board` relay arm, not a CommsService RPC. An agent writes only its own board, through the agent-gateway `update_board` arm; a non-agent caller gets `permission_denied`. In each `upsert_tasks` -entry an unset field keeps the stored value and a set field (including `""`) -replaces it. A present `refs` or `waiting` op replaces the list, and an empty -op clears it. A new task needs a `title`, and its `state` defaults to `ACTIVE`. +entry an unset field keeps the stored value and a set field replaces it; `""` +clears `note` or `primary_ref`, but a task's `title` is never empty. A present +`refs` or `waiting` op replaces the list, and an empty op clears it. A new task +needs a `title`, and its `state` defaults to `ACTIVE`. `remove_task_keys` drops tasks (removing the focus task clears the focus), and `halt` and `clear_halt` are mutually exclusive. An `automatic` patch never refreshes the board's written turn or time; an empty one returns the board From 01fbfdce7f2c424188de051af9c065f7400ff601 Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 14 Oct 2026 23:07:59 -0400 Subject: [PATCH 3/3] fix(server): fold comms forge setters to keep Serve under funlen (RIG-5172) --- go/server/serve.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/go/server/serve.go b/go/server/serve.go index d14ba211c..4bb6dcba4 100644 --- a/go/server/serve.go +++ b/go/server/serve.go @@ -757,9 +757,10 @@ func seedBootstrapAccounts(ctx context.Context, st *store.Store, cfg ServeConfig return admin, system, linearBridge, nil } -// boardForges is the forge coordinate set board refs resolve against. -func boardForges(cfg ForgeConfig) board.Forges { - return board.Forges{GitHubHost: cfg.resolved().Host, LinearHost: forge.LinearHost} +// setCommsForges gives comms the forge coordinates for ask refs and board reads. +func setCommsForges(c *comms.Comms, cfg ForgeConfig) { + c.SetForgeHost(cfg.resolved().Host) + c.SetBoardForges(board.Forges{GitHubHost: cfg.resolved().Host, LinearHost: forge.LinearHost}) } // Serve binds the compass.v1 service to cfg.SocketPath and drives it until ctx @@ -885,8 +886,7 @@ func Serve(ctx context.Context, cfg ServeConfig) error { commsBus := events.NewBus[*compassv1.SubscribeCommsResponse]() defer commsBus.Close() commsSvc := comms.NewComms(st, commsBus, fab, admin.ID) - commsSvc.SetForgeHost(cfg.Forge.resolved().Host) - commsSvc.SetBoardForges(boardForges(cfg.Forge)) + setCommsForges(commsSvc, cfg.Forge) // Register the coordination-channel reconcile as the store's in-tx hook, so // the two parent-edge writers auto-provision/reconcile a manager's // coordination channel atomically with the tree edge (RIG-1722 T5). Wired here