Files
tutti/packages/agent/store-sqlite/activity_session_pages.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

151 lines
5.6 KiB
Go

package storesqlite
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
// ListSessionsPage reads the canonical root-session search projection with the
// same stable conversation cursor used by rail section pages. A non-nil
// IncludedSessionIDs slice lets a composing host constrain the query to its
// authorized session set without reimplementing search, ordering, or paging.
func (s *Store) ListSessionsPage(
ctx context.Context,
input ListSessionsPageInput,
) (SessionListPage, bool, error) {
if s == nil || s.db == nil {
return SessionListPage{}, false, errors.New("workspace database is not initialized")
}
workspaceID := strings.TrimSpace(input.WorkspaceID)
if workspaceID == "" {
return SessionListPage{}, false, nil
}
predicates := []string{
"workspace_id = ?",
"session_kind = 'root'",
"deleted_at_unix_ms = 0",
"json_extract(session_metadata_json, '$.visible') IS NOT 0",
}
args := []any{workspaceID}
if agentTargetID := strings.TrimSpace(input.AgentTargetID); agentTargetID != "" {
predicates = append(predicates, "agent_target_id = ?")
args = append(args, agentTargetID)
}
if includedPredicate, includedArgs, err := includedSessionIDsPredicate("agent_session_id", input.IncludedSessionIDs); err != nil {
return SessionListPage{}, false, err
} else if includedPredicate != "" {
predicates = append(predicates, strings.TrimSpace(strings.TrimPrefix(includedPredicate, "AND ")))
args = append(args, includedArgs...)
}
for _, token := range strings.Fields(strings.ToLower(input.SearchQuery)) {
predicates = append(predicates, "LOWER(title) LIKE ? ESCAPE '\\'")
args = append(args, "%"+escapeSessionSearchLikeToken(token)+"%")
}
query := `
WITH session_rows AS (
SELECT workspace_agent_sessions.*,
COALESCE(
NULLIF((
SELECT latest.started_at_unix_ms
FROM workspace_agent_turns latest
WHERE latest.workspace_id = workspace_agent_sessions.workspace_id
AND latest.agent_session_id = workspace_agent_sessions.agent_session_id
AND NOT EXISTS (
SELECT 1 FROM workspace_agent_turn_history history
WHERE history.workspace_id = latest.workspace_id
AND history.agent_session_id = latest.agent_session_id
AND history.turn_id = latest.turn_id
AND history.history_state = 'retracted'
)
ORDER BY latest.updated_at_unix_ms DESC, latest.created_at_unix_ms DESC,
latest.started_at_unix_ms DESC, latest.turn_id DESC
LIMIT 1
), 0),
NULLIF(workspace_agent_sessions.started_at_unix_ms, 0),
workspace_agent_sessions.created_at_unix_ms
) AS conversation_sort_time_unix_ms
FROM workspace_agent_sessions
WHERE ` + strings.Join(predicates, "\n AND ") + `
)
SELECT workspace_id, agent_session_id, session_kind, root_agent_session_id, root_turn_id,
parent_agent_session_id, parent_turn_id, parent_tool_call_id,
origin, agent_target_id, provider, provider_session_id, model,
user_id, settings_json, session_metadata_json, internal_runtime_context_json, cwd,
rail_section_key,
title, message_version, last_event_at_unix_ms,
started_at_unix_ms, ended_at_unix_ms, pinned_at_unix_ms,
created_at_unix_ms, updated_at_unix_ms, active_turn_id,
conversation_sort_time_unix_ms
FROM session_rows
WHERE (? = '' OR conversation_sort_time_unix_ms < ? OR (conversation_sort_time_unix_ms = ? AND agent_session_id > ?))
ORDER BY conversation_sort_time_unix_ms DESC, agent_session_id ASC`
cursorSessionID := strings.TrimSpace(input.CursorSessionID)
args = append(args, cursorSessionID, input.CursorSortTimeUnixMS, input.CursorSortTimeUnixMS, cursorSessionID)
queryLimit := 0
if input.Limit > 0 {
queryLimit = input.Limit + 1
query += "\nLIMIT ?"
args = append(args, queryLimit)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return SessionListPage{}, false, fmt.Errorf("list workspace agent sessions page: %w", err)
}
defer rows.Close()
sessions := make([]Session, 0)
sortTimesUnixMS := make([]int64, 0)
for rows.Next() {
session, sortTimeUnixMS, err := scanAgentSessionWithSortTime(rows)
if err != nil {
return SessionListPage{}, false, err
}
sessions = append(sessions, session)
sortTimesUnixMS = append(sortTimesUnixMS, sortTimeUnixMS)
}
if err := rows.Err(); err != nil {
return SessionListPage{}, false, fmt.Errorf("iterate workspace agent sessions page: %w", err)
}
hasMore := false
if input.Limit > 0 && len(sessions) > input.Limit {
hasMore = true
sessions = sessions[:input.Limit]
sortTimesUnixMS = sortTimesUnixMS[:input.Limit]
}
nextCursor := ""
if hasMore && len(sessions) > 0 {
lastIndex := len(sessions) - 1
nextCursor = strconv.FormatInt(sortTimesUnixMS[lastIndex], 10) + "|" + strings.TrimSpace(sessions[lastIndex].ID)
}
return SessionListPage{
WorkspaceID: workspaceID,
Sessions: sessions,
HasMore: hasMore,
NextCursor: nextCursor,
}, true, nil
}
func escapeSessionSearchLikeToken(value string) string {
replacer := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_")
return replacer.Replace(value)
}
func includedSessionIDsPredicate(column string, sessionIDs []string) (string, []any, error) {
if sessionIDs == nil {
return "", nil, nil
}
includedJSON, err := json.Marshal(normalizedSessionIDs(sessionIDs))
if err != nil {
return "", nil, fmt.Errorf("encode included workspace agent session ids: %w", err)
}
return "AND " + column + " IN (SELECT CAST(value AS TEXT) FROM json_each(?))", []any{string(includedJSON)}, nil
}