package agentsessionstore import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "time" "github.com/tutti-os/tutti/packages/agent/daemon/httpx" controlplanehttp "github.com/tutti-os/tutti/packages/agent/daemon/internal/httpclient" ) func NewClient(cfg Config) *Client { httpClient := cfg.HTTPClient if httpClient == nil { httpClient = httpx.NewClient(defaultTimeout) } return &Client{ cfg: cfg, httpClient: httpClient, } } func (c *Client) ReportActivity(ctx context.Context, input ReportActivityInput) (ReportActivityReply, error) { workspaceID := strings.TrimSpace(input.WorkspaceID) if workspaceID == "" { return ReportActivityReply{}, errors.New("workspace id is required") } // Provider observations are local Replay metadata. They never cross the // canonical HTTP reporting boundary. input.ProviderObservations = nil return ReportActivityAsSessionUpdates(ctx, c, input) } func (c *Client) BindGoalProvenance(ctx context.Context, input BindGoalProvenanceInput) (GoalProvenanceBinding, error) { workspaceID := strings.TrimSpace(input.WorkspaceID) agentSessionID := strings.TrimSpace(input.AgentSessionID) if workspaceID == "" || agentSessionID == "" { return GoalProvenanceBinding{}, errors.New("workspace id and agent session id are required") } input.WorkspaceID = workspaceID input.AgentSessionID = agentSessionID requestBody, err := marshalRequestBody(input) if err != nil { return GoalProvenanceBinding{}, fmt.Errorf("prepare goal provenance bind request: %w", err) } endpoint := fmt.Sprintf("%s/rooms/%s/agents/sessions/%s/goal-provenance/bind", resolveSessionAPIPrefix(c.cfg.BaseURL), url.PathEscape(workspaceID), url.PathEscape(agentSessionID)) var binding GoalProvenanceBinding if err := c.postJSONWithTransientRemoteRetry(ctx, endpoint, requestBody, &binding); err != nil { return GoalProvenanceBinding{}, WithRequestBodyBytes(err, len(requestBody)) } return binding, nil } func (c *Client) LookupGoalProvenance(ctx context.Context, input LookupGoalProvenanceInput) (GoalProvenanceBinding, bool, error) { workspaceID := strings.TrimSpace(input.WorkspaceID) agentSessionID := strings.TrimSpace(input.AgentSessionID) if workspaceID == "" || agentSessionID == "" { return GoalProvenanceBinding{}, false, errors.New("workspace id and agent session id are required") } input.WorkspaceID = workspaceID input.AgentSessionID = agentSessionID requestBody, err := marshalRequestBody(input) if err != nil { return GoalProvenanceBinding{}, false, fmt.Errorf("prepare goal provenance lookup request: %w", err) } endpoint := fmt.Sprintf("%s/rooms/%s/agents/sessions/%s/goal-provenance/lookup", resolveSessionAPIPrefix(c.cfg.BaseURL), url.PathEscape(workspaceID), url.PathEscape(agentSessionID)) var reply LookupGoalProvenanceReply if err := c.postJSONWithTransientRemoteRetry(ctx, endpoint, requestBody, &reply); err != nil { return GoalProvenanceBinding{}, false, WithRequestBodyBytes(err, len(requestBody)) } return reply.Binding, reply.Found, nil } func (c *Client) ReportGoalReconcileRequired(ctx context.Context, input ReportGoalReconcileRequiredInput) (ReportGoalReconcileRequiredReply, error) { workspaceID := strings.TrimSpace(input.WorkspaceID) agentSessionID := strings.TrimSpace(input.Request.AgentSessionID) if workspaceID == "" || agentSessionID == "" { return ReportGoalReconcileRequiredReply{}, errors.New("workspace id and agent session id are required") } input.WorkspaceID = workspaceID input.Request.AgentSessionID = agentSessionID requestBody, err := marshalRequestBody(input.Request) if err != nil { return ReportGoalReconcileRequiredReply{}, fmt.Errorf("prepare goal reconcile request: %w", err) } endpoint := fmt.Sprintf("%s/rooms/%s/agents/sessions/%s/goal-reconcile-required", resolveSessionAPIPrefix(c.cfg.BaseURL), url.PathEscape(workspaceID), url.PathEscape(agentSessionID)) var reply ReportGoalReconcileRequiredReply if err := c.postJSONWithTransientRemoteRetry(ctx, endpoint, requestBody, &reply); err != nil { return ReportGoalReconcileRequiredReply{}, WithRequestBodyBytes(err, len(requestBody)) } return reply, nil } func (c *Client) ReportSessionState(ctx context.Context, input ReportSessionStateInput) (ReportSessionStateReply, error) { workspaceID := strings.TrimSpace(input.WorkspaceID) if workspaceID == "" { return ReportSessionStateReply{}, errors.New("workspace id is required") } agentSessionID := strings.TrimSpace(input.AgentSessionID) if agentSessionID == "" { return ReportSessionStateReply{}, errors.New("agent session id is required") } endpoint := fmt.Sprintf( "%s/rooms/%s/agents/sessions/%s/state", resolveSessionAPIPrefix(c.cfg.BaseURL), url.PathEscape(workspaceID), url.PathEscape(agentSessionID), ) // Metadata resolution: explicit input fields win, then the source, then // (for the state document) the state itself. Existing non-empty values // are left byte-identical in place; resolution only fills empty slots, so // callers that carry no metadata anywhere produce an unchanged request. agentTargetID := firstNonEmptyString(input.AgentTargetID, input.Source.AgentTargetID, input.State.AgentTargetID) deviceID := firstNonEmptyString(input.DeviceID, input.Source.DeviceID, input.State.DeviceID) source := input.Source if agentTargetID != "" && strings.TrimSpace(source.AgentTargetID) == "" { source.AgentTargetID = agentTargetID } if deviceID != "" && strings.TrimSpace(source.DeviceID) == "" { source.DeviceID = deviceID } state := sanitizeSessionStateUpdateForUpstream(input.State) if agentTargetID != "" && strings.TrimSpace(state.AgentTargetID) == "" { state.AgentTargetID = agentTargetID } if deviceID != "" && strings.TrimSpace(state.DeviceID) == "" { state.DeviceID = deviceID } requestBody, err := marshalRequestBody(reportSessionStateRequest{ WorkspaceID: workspaceID, AgentSessionID: agentSessionID, AgentTargetID: agentTargetID, DeviceID: deviceID, SessionOrigin: sessionOriginCanonicalRequestValue(input.SessionOrigin), Connector: input.Connector, Source: &source, State: state, }) if err != nil { return ReportSessionStateReply{}, fmt.Errorf("prepare agent session state request: %w", err) } var reply ReportSessionStateReply reply.RequestBodyBytes = len(requestBody) if err := c.postJSONWithTransientRemoteRetry(ctx, endpoint, requestBody, &reply); err != nil { return reply, WithRequestBodyBytes(err, reply.RequestBodyBytes) } reply.RequestBodyBytes = len(requestBody) return reply, nil } func (c *Client) ReportSessionMessages(ctx context.Context, input ReportSessionMessagesInput) (ReportSessionMessagesReply, error) { workspaceID := strings.TrimSpace(input.WorkspaceID) if workspaceID == "" { return ReportSessionMessagesReply{}, errors.New("workspace id is required") } agentSessionID := strings.TrimSpace(input.AgentSessionID) if agentSessionID == "" { return ReportSessionMessagesReply{}, errors.New("agent session id is required") } endpoint := fmt.Sprintf( "%s/rooms/%s/agents/sessions/%s/messages", resolveSessionAPIPrefix(c.cfg.BaseURL), url.PathEscape(workspaceID), url.PathEscape(agentSessionID), ) // Metadata resolution mirrors ReportSessionState: explicit input fields // win, then the source; only empty slots are filled. agentTargetID := firstNonEmptyString(input.AgentTargetID, input.Source.AgentTargetID) deviceID := firstNonEmptyString(input.DeviceID, input.Source.DeviceID) source := input.Source if agentTargetID != "" && strings.TrimSpace(source.AgentTargetID) == "" { source.AgentTargetID = agentTargetID } if deviceID != "" && strings.TrimSpace(source.DeviceID) == "" { source.DeviceID = deviceID } requestBatches, err := marshalReportSessionMessagesRequestsForUpload(reportSessionMessagesRequest{ WorkspaceID: workspaceID, AgentTargetID: agentTargetID, DeviceID: deviceID, SessionOrigin: sessionOriginCanonicalRequestValue(input.SessionOrigin), Connector: input.Connector, Source: &source, Updates: sanitizeSessionMessageUpdatesForUpstream(input.Updates), }) if err != nil { return ReportSessionMessagesReply{}, fmt.Errorf("prepare agent session messages request: %w", err) } var reply ReportSessionMessagesReply for _, batch := range requestBatches { reply.RequestBodyBytes += len(batch) var batchReply ReportSessionMessagesReply err = c.postJSONWithTransientRemoteRetry(ctx, endpoint, batch, &batchReply) if err != nil { return reply, WithRequestBodyBytes(err, len(batch)) } reply.AcceptedCount += batchReply.AcceptedCount if batchReply.LatestVersion > reply.LatestVersion { reply.LatestVersion = batchReply.LatestVersion } } return reply, nil } func (c *Client) ReportActivityJSON(ctx context.Context, workspaceID string, payload json.RawMessage) (ReportActivityReply, error) { workspaceID = strings.TrimSpace(workspaceID) if workspaceID == "" { return ReportActivityReply{}, errors.New("workspace id is required") } input, err := DecodeReportActivityJSON(payload) if err != nil { return ReportActivityReply{}, fmt.Errorf("decode agent activity request: %w", err) } input.WorkspaceID = workspaceID return ReportActivityAsSessionUpdates(ctx, c, input) } func DecodeReportActivityJSON(payload json.RawMessage) (ReportActivityInput, error) { var request reportActivityRequest if err := json.Unmarshal(payload, &request); err != nil { return ReportActivityInput{}, err } input := ReportActivityInput{ TimelineItems: request.TimelineItems, StatePatches: request.StatePatches, MessageUpdates: request.MessageUpdates, SessionAudits: request.SessionAudits, GoalReconcileRequests: request.GoalReconcileRequests, } if request.Connector != nil { connector := *request.Connector input.Connector = &connector } if request.Source != nil { input.Source = *request.Source } return input, nil } func (c *Client) ListAgents(ctx context.Context, workspaceID string) (*WorkspaceAgentSnapshot, error) { return c.ListAgentsWithOrigin(ctx, workspaceID, "") } func (c *Client) ListAgentsWithOrigin(ctx context.Context, workspaceID string, sessionOrigin string) (*WorkspaceAgentSnapshot, error) { return c.ListAgentsWithFilter(ctx, ListAgentsInput{ WorkspaceID: workspaceID, SessionOrigin: sessionOrigin, }) } func (c *Client) ListAgentsWithFilter(ctx context.Context, input ListAgentsInput) (*WorkspaceAgentSnapshot, error) { var snapshot WorkspaceAgentSnapshot endpoint := fmt.Sprintf( "%s/rooms/%s/agents/list", resolveAPIPrefix(c.cfg.BaseURL), url.PathEscape(input.WorkspaceID), ) query := url.Values{} if origin := sessionOriginCanonicalQueryValue(input.SessionOrigin); origin != "" { query.Set("session_origin", origin) } if userID := strings.TrimSpace(input.UserID); userID != "" { query.Set("user_id", userID) } if deviceID := strings.TrimSpace(input.DeviceID); deviceID != "" { query.Set("device_id", deviceID) } if encoded := query.Encode(); encoded != "" { endpoint += "?" + encoded } err := c.doJSONWithTransientRemoteRetry(ctx, http.MethodGet, endpoint, nil, &snapshot) if err != nil { return nil, err } return &snapshot, nil } func (c *Client) GetGlobalAgentActivityFilterOptions(ctx context.Context) (*GlobalAgentActivityFilterOptions, error) { var options GlobalAgentActivityFilterOptions if err := c.doJSONWithTransientRemoteRetry(ctx, http.MethodGet, "/v2/agent-activity/filter-options", nil, &options); err != nil { return nil, err } return &options, nil } func (c *Client) ListGlobalAgentActivitySessions(ctx context.Context, input ListGlobalAgentActivitySessionsInput) (*ListGlobalAgentActivitySessionsReply, error) { roomIDs := normalizeGlobalAgentActivityFilterValues(input.RoomIDs) ownerUserIDs := normalizeGlobalAgentActivityFilterValues(input.SessionOwnerUserIDs) agentKeys := normalizeGlobalAgentActivityFilterValues(input.AgentKeys) if input.ActivityFromUnixMS < 0 || input.ActivityToUnixMS < 0 { return nil, errors.New("global agent activity time filters must be non-negative") } if input.ActivityFromUnixMS > 0 && input.ActivityToUnixMS > 0 && input.ActivityFromUnixMS >= input.ActivityToUnixMS { return nil, errors.New("global agent activity start time must be before end time") } if len(roomIDs) == 0 && len(ownerUserIDs) == 0 && len(agentKeys) == 0 && input.ActivityFromUnixMS == 0 && input.ActivityToUnixMS == 0 { return nil, errors.New("at least one global agent activity filter is required") } query := url.Values{} for _, roomID := range roomIDs { query.Add("roomIds", roomID) } for _, userID := range ownerUserIDs { query.Add("sessionOwnerUserIds", userID) } for _, agentKey := range agentKeys { query.Add("agentKeys", agentKey) } if input.ActivityFromUnixMS > 0 { query.Set("activityFromUnixMs", strconv.FormatInt(input.ActivityFromUnixMS, 10)) } if input.ActivityToUnixMS > 0 { query.Set("activityToUnixMs", strconv.FormatInt(input.ActivityToUnixMS, 10)) } endpoint := "/v2/agent-activity/sessions" if encoded := query.Encode(); encoded != "" { endpoint += "?" + encoded } var reply ListGlobalAgentActivitySessionsReply if err := c.doJSONWithTransientRemoteRetry(ctx, http.MethodGet, endpoint, nil, &reply); err != nil { return nil, err } return &reply, nil } func normalizeGlobalAgentActivityFilterValues(values []string) []string { normalized := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} normalized = append(normalized, value) } return normalized } type DeleteAgentSessionInput struct { WorkspaceID string AgentSessionID string SessionOrigin string } func (c *Client) DeleteAgentSession(ctx context.Context, input DeleteAgentSessionInput) error { query := url.Values{} if origin := sessionOriginCanonicalQueryValue(input.SessionOrigin); origin != "" { query.Set("session_origin", origin) } endpoint := fmt.Sprintf( "%s/rooms/%s/agents/%s", resolveAPIPrefix(c.cfg.BaseURL), url.PathEscape(input.WorkspaceID), url.PathEscape(input.AgentSessionID), ) if encoded := query.Encode(); encoded != "" { endpoint += "?" + encoded } return c.doJSON(ctx, http.MethodDelete, endpoint, nil, nil) } func (c *Client) ListSessionMessages(ctx context.Context, input ListSessionMessagesInput) (*ListSessionMessagesReply, error) { workspaceID := strings.TrimSpace(input.WorkspaceID) if workspaceID == "" { return nil, errors.New("workspace id is required") } agentSessionID := strings.TrimSpace(input.AgentSessionID) if agentSessionID == "" { return nil, errors.New("agent session id is required") } query := url.Values{} if input.AfterVersion > 0 { query.Set("after_version", strconv.FormatUint(input.AfterVersion, 10)) } if input.Limit > 0 { query.Set("limit", strconv.Itoa(input.Limit)) } if origin := sessionOriginCanonicalQueryValue(input.SessionOrigin); origin != "" { query.Set("session_origin", origin) } if deviceID := strings.TrimSpace(input.DeviceID); deviceID != "" { query.Set("device_id", deviceID) } endpoint := fmt.Sprintf( "%s/rooms/%s/agents/sessions/%s/messages", resolveSessionAPIPrefix(c.cfg.BaseURL), url.PathEscape(workspaceID), url.PathEscape(agentSessionID), ) if encoded := query.Encode(); encoded != "" { endpoint += "?" + encoded } var messages ListSessionMessagesReply if err := c.doJSONWithTransientRemoteRetry(ctx, http.MethodGet, endpoint, nil, &messages); err != nil { return nil, err } return &messages, nil } func (c *Client) postJSON(ctx context.Context, endpoint string, requestBody any, responseBody any) error { return c.doJSON(ctx, http.MethodPost, endpoint, requestBody, responseBody) } func (c *Client) postJSONWithTransientRemoteRetry( ctx context.Context, endpoint string, requestBody any, responseBody any, ) error { var err error for attempt := 0; ; attempt++ { err = c.postJSON(ctx, endpoint, requestBody, responseBody) if err == nil || !shouldRetryTransientRemotePost(c.cfg.BaseURL, err) { return err } if attempt >= len(controlplanehttp.DefaultTransientBackoffs) || ctx.Err() != nil { return err } if waitErr := sleepWithContext(ctx, controlplanehttp.DefaultTransientBackoffs[attempt]); waitErr != nil { return err } } } func (c *Client) doJSONWithTransientRemoteRetry( ctx context.Context, method string, endpoint string, requestBody any, responseBody any, ) error { var err error for attempt := 0; ; attempt++ { err = c.doJSON(ctx, method, endpoint, requestBody, responseBody) if err == nil || !shouldRetryTransientRemoteRead(c.cfg.BaseURL, method, err) { return err } if attempt >= len(controlplanehttp.DefaultTransientBackoffs) || ctx.Err() != nil { return err } if waitErr := sleepWithContext(ctx, controlplanehttp.DefaultTransientBackoffs[attempt]); waitErr != nil { return err } } } func sessionOriginCanonicalQueryValue(origin string) string { return canonicalSessionOriginValue(origin) } func sessionOriginCanonicalRequestValue(origin string) string { return canonicalSessionOriginValue(origin) } func (c *Client) doJSON(ctx context.Context, method, endpoint string, requestBody any, responseBody any) error { baseURL := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/") if baseURL == "" { return fmt.Errorf("agent activity base url is required") } var body io.Reader if requestBody != nil { raw, err := marshalRequestBody(requestBody) if err != nil { return fmt.Errorf("marshal agent activity request: %w", err) } body = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, baseURL+endpoint, body) if err != nil { return err } c.setHeaders(req) res, err := c.httpClient.Do(req) if err != nil { return err } defer func() { _ = res.Body.Close() }() raw, err := io.ReadAll(io.LimitReader(res.Body, 2<<20)) if err != nil { return err } if res.StatusCode < 200 || res.StatusCode >= 300 { return HTTPError{StatusCode: res.StatusCode, Body: strings.TrimSpace(string(raw)), Header: res.Header.Clone()} } if responseBody == nil || len(bytes.TrimSpace(raw)) == 0 { return nil } if err := json.Unmarshal(raw, responseBody); err != nil { return fmt.Errorf("decode agent activity response: %w", err) } return nil } func marshalRequestBody(requestBody any) ([]byte, error) { switch value := requestBody.(type) { case json.RawMessage: return value, nil case []byte: return value, nil default: return json.Marshal(requestBody) } } // SanitizeTimelineItemsForRelay reuses the upstream payload sanitation rules for // local relay transports so oversized tool outputs do not block activity reporting. func (c *Client) setHeaders(req *http.Request) { req.Header.Set("Accept", "application/json") if req.Body != nil { req.Header.Set("Content-Type", "application/json") } if cookie := strings.TrimSpace(c.cfg.SessionCookie); cookie != "" { req.Header.Set("Cookie", cookie) } if userID := strings.TrimSpace(c.cfg.UserID); userID != "" { req.Header.Set("X-User-Id", userID) } if token := strings.TrimSpace(c.cfg.Token); token != "" { req.Header.Set("Authorization", "Bearer "+token) } if lane := strings.TrimSpace(c.cfg.PPELane); lane != "" { req.Header.Set("x-zk-ppe-lane", lane) } } func resolveAPIPrefix(baseURL string) string { if isLoopbackBaseURL(baseURL) { return localAPIPrefix } return remoteAPIPrefix } func resolveSessionAPIPrefix(baseURL string) string { if isLoopbackBaseURL(baseURL) { return localAPIPrefix } return remoteAPIPrefix } func shouldRetryTransientRemotePost(baseURL string, err error) bool { if err == nil || resolveAPIPrefix(baseURL) != remoteAPIPrefix { return false } return controlplanehttp.IsTransientNetworkError(err) } func shouldRetryTransientRemoteRead(baseURL string, method string, err error) bool { if err == nil || resolveAPIPrefix(baseURL) != remoteAPIPrefix { return false } if method != http.MethodGet { return false } return controlplanehttp.IsTransientNetworkError(err) } func sleepWithContext(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func isLoopbackBaseURL(raw string) bool { host := baseURLHostname(raw) if host == "" { return false } if strings.EqualFold(host, "localhost") || host == "0.0.0.0" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } func baseURLHostname(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } parsed, err := url.Parse(raw) if err == nil && parsed.Hostname() != "" { return strings.TrimSpace(parsed.Hostname()) } if strings.Contains(raw, "://") { return "" } parsed, err = url.Parse("http://" + raw) if err != nil { return "" } return strings.TrimSpace(parsed.Hostname()) } type reportActivityRequest struct { Connector *ConnectorInfo `json:"connector,omitempty"` Source *EventSource `json:"source,omitempty"` TimelineItems []WorkspaceAgentTimelineItem `json:"timelineItems,omitempty"` StatePatches []WorkspaceAgentStatePatch `json:"statePatches,omitempty"` MessageUpdates []WorkspaceAgentMessageUpdate `json:"messageUpdates,omitempty"` SessionAudits []WorkspaceAgentSessionAuditUpdate `json:"sessionAudits,omitempty"` GoalReconcileRequests []WorkspaceAgentGoalReconcileRequest `json:"goalReconcileRequests,omitempty"` } type reportSessionStateRequest struct { WorkspaceID string `json:"roomId,omitempty"` AgentSessionID string `json:"agentSessionId,omitempty"` AgentTargetID string `json:"agentTargetId,omitempty"` DeviceID string `json:"deviceId,omitempty"` SessionOrigin string `json:"sessionOrigin,omitempty"` Source *EventSource `json:"source,omitempty"` Connector *ConnectorInfo `json:"connector,omitempty"` State WorkspaceAgentSessionStateUpdate `json:"state,omitempty"` } type reportSessionMessagesRequest struct { WorkspaceID string `json:"roomId,omitempty"` AgentTargetID string `json:"agentTargetId,omitempty"` DeviceID string `json:"deviceId,omitempty"` SessionOrigin string `json:"sessionOrigin,omitempty"` Source *EventSource `json:"source,omitempty"` Connector *ConnectorInfo `json:"connector,omitempty"` Updates []WorkspaceAgentSessionMessageUpdate `json:"updates,omitempty"` } type flexibleUint64 uint64 func (v *flexibleUint64) UnmarshalJSON(data []byte) error { parsed, err := parseFlexibleUint64(data) if err != nil { return err } *v = flexibleUint64(parsed) return nil } type flexibleInt64 int64 func (v *flexibleInt64) UnmarshalJSON(data []byte) error { parsed, err := parseFlexibleInt64(data) if err != nil { return err } *v = flexibleInt64(parsed) return nil } func parseFlexibleUint64(data []byte) (uint64, error) { text := strings.TrimSpace(string(data)) if text == "" || text == "null" { return 0, nil } if strings.HasPrefix(text, `"`) { var value string if err := json.Unmarshal(data, &value); err != nil { return 0, err } text = strings.TrimSpace(value) if text == "" { return 0, nil } } return strconv.ParseUint(text, 10, 64) } func parseFlexibleInt64(data []byte) (int64, error) { text := strings.TrimSpace(string(data)) if text == "" || text == "null" { return 0, nil } if strings.HasPrefix(text, `"`) { var value string if err := json.Unmarshal(data, &value); err != nil { return 0, err } text = strings.TrimSpace(value) if text == "" { return 0, nil } } return strconv.ParseInt(text, 10, 64) }