Files
tutti/packages/agent/store-sqlite/activity_deleted_sessions.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

557 lines
21 KiB
Go

package storesqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strconv"
"strings"
)
var (
ErrDeletedSessionNotFound = errors.New("deleted workspace agent session not found")
ErrDeletedSessionNotRestorable = errors.New("deleted workspace agent session is not restorable")
)
const (
defaultDeletedSessionPageLimit = 50
maximumDeletedSessionPageLimit = 100
)
func (s *Store) ListDeletedSessions(ctx context.Context, input ListDeletedSessionsInput) (DeletedSessionPage, error) {
if s == nil || s.db == nil {
return DeletedSessionPage{}, errors.New("workspace database is not initialized")
}
workspaceID := strings.TrimSpace(input.WorkspaceID)
if workspaceID == "" {
return DeletedSessionPage{}, nil
}
limit := input.Limit
if limit <= 0 {
limit = defaultDeletedSessionPageLimit
}
if limit > maximumDeletedSessionPageLimit {
limit = maximumDeletedSessionPageLimit
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return DeletedSessionPage{}, fmt.Errorf("begin list deleted workspace agent sessions: %w", err)
}
defer func() { _ = tx.Rollback() }()
predicates := []string{
"s.workspace_id = ?",
"s.deleted_at_unix_ms > 0",
`NOT EXISTS (
SELECT 1 FROM workspace_agent_sessions parent
WHERE parent.workspace_id = s.workspace_id
AND parent.agent_session_id = s.parent_agent_session_id
AND parent.deleted_at_unix_ms > 0
)`,
}
args := []any{workspaceID}
if input.RailSectionKey != nil {
railSectionKey := strings.TrimSpace(*input.RailSectionKey)
if railSectionKey == "" {
return DeletedSessionPage{}, errors.New("rail section key is required")
}
predicates = append(predicates, "s.rail_section_key = ?")
args = append(args, railSectionKey)
}
for _, token := range strings.Fields(strings.ToLower(input.SearchQuery)) {
predicates = append(predicates, "LOWER(s.title) LIKE ? ESCAPE '\\'")
args = append(args, "%"+escapeSessionSearchLikeToken(token)+"%")
}
where := strings.Join(predicates, " AND ")
var totalCount int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspace_agent_sessions s WHERE `+where, args...).Scan(&totalCount); err != nil {
return DeletedSessionPage{}, fmt.Errorf("count deleted workspace agent sessions: %w", err)
}
var workspaceTotalCount int
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*) FROM workspace_agent_sessions s
WHERE s.workspace_id = ? AND s.deleted_at_unix_ms > 0
AND NOT EXISTS (
SELECT 1 FROM workspace_agent_sessions parent
WHERE parent.workspace_id = s.workspace_id
AND parent.agent_session_id = s.parent_agent_session_id
AND parent.deleted_at_unix_ms > 0
)
`, workspaceID).Scan(&workspaceTotalCount); err != nil {
return DeletedSessionPage{}, fmt.Errorf("count all deleted workspace agent sessions: %w", err)
}
railSections, err := listDeletedSessionRailSectionsTx(ctx, tx, workspaceID)
if err != nil {
return DeletedSessionPage{}, err
}
pagePredicates := append([]string(nil), predicates...)
pageArgs := append([]any(nil), args...)
cursorID := strings.TrimSpace(input.CursorAgentSessionID)
if cursorID != "" {
pagePredicates = append(pagePredicates, "(s.updated_at_unix_ms < ? OR (s.updated_at_unix_ms = ? AND s.agent_session_id > ?))")
pageArgs = append(pageArgs, input.CursorUpdatedAtUnixMS, input.CursorUpdatedAtUnixMS, cursorID)
}
pageArgs = append(pageArgs, limit+1)
rows, err := tx.QueryContext(ctx, `
SELECT s.agent_session_id, s.title, s.rail_section_key, s.rail_project_path,
s.updated_at_unix_ms, s.deleted_at_unix_ms, s.recoverable_delete_version,
s.recoverable_delete_tree_size
FROM workspace_agent_sessions s
WHERE `+strings.Join(pagePredicates, " AND ")+`
ORDER BY s.updated_at_unix_ms DESC, s.agent_session_id ASC
LIMIT ?
`, pageArgs...)
if err != nil {
return DeletedSessionPage{}, fmt.Errorf("list deleted workspace agent sessions: %w", err)
}
defer rows.Close()
type deletedRow struct {
summary DeletedSessionSummary
version int64
treeSize int
}
pageRows := make([]deletedRow, 0, limit+1)
for rows.Next() {
var row deletedRow
if err := rows.Scan(
&row.summary.AgentSessionID,
&row.summary.Title,
&row.summary.RailSectionKey,
&row.summary.ProjectPath,
&row.summary.UpdatedAtUnixMS,
&row.summary.DeletedAtUnixMS,
&row.version,
&row.treeSize,
); err != nil {
return DeletedSessionPage{}, fmt.Errorf("scan deleted workspace agent session: %w", err)
}
row.summary.RailSectionKey = strings.TrimSpace(row.summary.RailSectionKey)
if row.summary.RailSectionKey == "" {
return DeletedSessionPage{}, fmt.Errorf("deleted workspace agent session %q has no rail section key", row.summary.AgentSessionID)
}
pageRows = append(pageRows, row)
}
if err := rows.Err(); err != nil {
return DeletedSessionPage{}, fmt.Errorf("iterate deleted workspace agent sessions: %w", err)
}
hasMore := len(pageRows) > limit
if hasMore {
pageRows = pageRows[:limit]
}
summaries := make([]DeletedSessionSummary, 0, len(pageRows))
for _, row := range pageRows {
reason, _, err := deletedSessionRestorabilityTx(
ctx, tx, workspaceID, row.summary.AgentSessionID, row.summary.DeletedAtUnixMS, row.version, row.treeSize,
)
if err != nil {
return DeletedSessionPage{}, err
}
row.summary.Restorable = reason == ""
row.summary.UnavailableReason = reason
summaries = append(summaries, row.summary)
}
nextCursor := ""
if hasMore && len(summaries) > 0 {
last := summaries[len(summaries)-1]
nextCursor = strconv.FormatInt(last.UpdatedAtUnixMS, 10) + "|" + strings.TrimSpace(last.AgentSessionID)
}
return DeletedSessionPage{
WorkspaceID: workspaceID, Sessions: summaries, RailSections: railSections,
TotalCount: totalCount, WorkspaceTotalCount: workspaceTotalCount,
HasMore: hasMore, NextCursor: nextCursor,
}, nil
}
func listDeletedSessionRailSectionsTx(ctx context.Context, tx *sql.Tx, workspaceID string) ([]DeletedSessionRailSection, error) {
rows, err := tx.QueryContext(ctx, `
SELECT s.rail_section_key, MIN(NULLIF(s.rail_project_path, ''))
FROM workspace_agent_sessions s
WHERE s.workspace_id = ? AND s.deleted_at_unix_ms > 0
AND TRIM(s.rail_section_key) <> ''
AND s.rail_section_key <> ?
AND NOT EXISTS (
SELECT 1 FROM workspace_agent_sessions parent
WHERE parent.workspace_id = s.workspace_id
AND parent.agent_session_id = s.parent_agent_session_id
AND parent.deleted_at_unix_ms > 0
)
GROUP BY s.rail_section_key
ORDER BY s.rail_section_key ASC
`, workspaceID, RailSectionKeyConversations)
if err != nil {
return nil, fmt.Errorf("list deleted workspace agent session rail sections: %w", err)
}
defer rows.Close()
sections := make([]DeletedSessionRailSection, 0)
for rows.Next() {
var section DeletedSessionRailSection
var projectPath sql.NullString
if err := rows.Scan(&section.RailSectionKey, &projectPath); err != nil {
return nil, fmt.Errorf("scan deleted workspace agent session rail section: %w", err)
}
section.RailSectionKey = strings.TrimSpace(section.RailSectionKey)
section.ProjectPath = strings.TrimSpace(projectPath.String)
sections = append(sections, section)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate deleted workspace agent session rail sections: %w", err)
}
return sections, nil
}
// deletedSessionRestorabilityTx returns the presentation reason and the full
// anchor-first reachable component. A version-one topmost tombstone is
// restorable only when every descendant belongs to the same deletion
// generation and the stored component size proves that no member is missing.
func deletedSessionRestorabilityTx(
ctx context.Context,
tx *sql.Tx,
workspaceID string,
componentSessionID string,
deletedAtUnixMS int64,
version int64,
expectedTreeSize int,
) (string, []string, error) {
if version != recoverableDeleteVersionCurrent || expectedTreeSize <= 0 {
return DeletedSessionUnavailableLegacyData, nil, nil
}
rows, err := tx.QueryContext(ctx, `
WITH RECURSIVE reachable(agent_session_id, depth) AS (
SELECT anchor.agent_session_id, 0
FROM workspace_agent_sessions anchor
WHERE anchor.workspace_id = ? AND anchor.agent_session_id = ?
AND anchor.deleted_at_unix_ms > 0
AND NOT EXISTS (
SELECT 1 FROM workspace_agent_sessions parent
WHERE parent.workspace_id = anchor.workspace_id
AND parent.agent_session_id = anchor.parent_agent_session_id
AND parent.deleted_at_unix_ms > 0
)
UNION
SELECT child.agent_session_id, parent.depth + 1
FROM workspace_agent_sessions child
JOIN reachable parent ON child.parent_agent_session_id = parent.agent_session_id
WHERE child.workspace_id = ? AND child.session_kind = 'child'
)
SELECT r.agent_session_id, r.depth,
s.deleted_at_unix_ms, s.recoverable_delete_version,
s.recoverable_delete_tree_size
FROM reachable r
JOIN workspace_agent_sessions s
ON s.workspace_id = ? AND s.agent_session_id = r.agent_session_id
ORDER BY r.depth ASC, r.agent_session_id ASC
`, workspaceID, componentSessionID, workspaceID, workspaceID)
if err != nil {
return "", nil, fmt.Errorf("resolve deleted workspace agent session tree: %w", err)
}
defer rows.Close()
ids := make([]string, 0)
for rows.Next() {
var id string
var depth, memberDeletedAt, memberVersion int64
var memberTreeSize int
if err := rows.Scan(&id, &depth, &memberDeletedAt, &memberVersion, &memberTreeSize); err != nil {
return "", nil, fmt.Errorf("scan deleted workspace agent session tree: %w", err)
}
if memberDeletedAt != deletedAtUnixMS || memberDeletedAt <= 0 ||
memberVersion != recoverableDeleteVersionCurrent || memberTreeSize != expectedTreeSize {
return DeletedSessionUnavailableIncompleteTree, nil, nil
}
ids = append(ids, strings.TrimSpace(id))
}
if err := rows.Err(); err != nil {
return "", nil, fmt.Errorf("iterate deleted workspace agent session tree: %w", err)
}
if len(ids) == 0 {
return DeletedSessionUnavailableIncompleteTree, nil, nil
}
if len(ids) != expectedTreeSize {
return DeletedSessionUnavailableIncompleteTree, nil, nil
}
return "", ids, nil
}
func (s *Store) RestoreDeletedSession(ctx context.Context, input RestoreDeletedSessionInput) (RestoreDeletedSessionResult, error) {
if s == nil || s.db == nil {
return RestoreDeletedSessionResult{}, errors.New("workspace database is not initialized")
}
workspaceID := strings.TrimSpace(input.WorkspaceID)
componentSessionID := strings.TrimSpace(input.AgentSessionID)
if workspaceID == "" || componentSessionID == "" {
return RestoreDeletedSessionResult{}, ErrDeletedSessionNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return RestoreDeletedSessionResult{}, fmt.Errorf("begin restore deleted workspace agent session: %w", err)
}
defer func() { _ = tx.Rollback() }()
var deletedAtUnixMS, version int64
var expectedTreeSize int
if err := tx.QueryRowContext(ctx, `
SELECT deleted_at_unix_ms, recoverable_delete_version, recoverable_delete_tree_size
FROM workspace_agent_sessions anchor
WHERE anchor.workspace_id = ? AND anchor.agent_session_id = ?
AND anchor.deleted_at_unix_ms > 0
AND NOT EXISTS (
SELECT 1 FROM workspace_agent_sessions parent
WHERE parent.workspace_id = anchor.workspace_id
AND parent.agent_session_id = anchor.parent_agent_session_id
AND parent.deleted_at_unix_ms > 0
)
`, workspaceID, componentSessionID).Scan(&deletedAtUnixMS, &version, &expectedTreeSize); errors.Is(err, sql.ErrNoRows) || deletedAtUnixMS <= 0 {
return RestoreDeletedSessionResult{}, ErrDeletedSessionNotFound
} else if err != nil {
return RestoreDeletedSessionResult{}, fmt.Errorf("read deleted workspace agent session: %w", err)
}
reason, sessionIDs, err := deletedSessionRestorabilityTx(ctx, tx, workspaceID, componentSessionID, deletedAtUnixMS, version, expectedTreeSize)
if err != nil {
return RestoreDeletedSessionResult{}, err
}
if reason != "" {
return RestoreDeletedSessionResult{}, fmt.Errorf("%w: %s", ErrDeletedSessionNotRestorable, reason)
}
mutations := make([]TransactionMutation, 0, len(sessionIDs))
for _, sessionID := range sessionIDs {
result, err := tx.ExecContext(ctx, `
UPDATE workspace_agent_sessions
SET deleted_at_unix_ms = 0,
recoverable_delete_version = 0,
recoverable_delete_tree_size = 0,
active_turn_id = NULL
WHERE workspace_id = ? AND agent_session_id = ?
AND deleted_at_unix_ms = ? AND recoverable_delete_version = ?
`, workspaceID, sessionID, deletedAtUnixMS, recoverableDeleteVersionCurrent)
if err != nil {
return RestoreDeletedSessionResult{}, fmt.Errorf("restore workspace agent session tree member: %w", err)
}
changed, err := rowsWereAffected(result, "restore workspace agent session tree member")
if err != nil {
return RestoreDeletedSessionResult{}, err
}
if !changed {
return RestoreDeletedSessionResult{}, fmt.Errorf("%w: %s", ErrDeletedSessionNotRestorable, DeletedSessionUnavailableIncompleteTree)
}
mutations = append(mutations, transactionMutation(workspaceID, sessionID, MutationEntitySession, sessionID, "restore", deletedAtUnixMS))
}
delta, err := s.commitTransaction(ctx, tx, workspaceID, mutations)
if err != nil {
return RestoreDeletedSessionResult{}, fmt.Errorf("commit restore deleted workspace agent session: %w", err)
}
sort.Strings(sessionIDs)
return RestoreDeletedSessionResult{
TransactionID: delta.TransactionID, CommitDelta: delta,
Restored: true, RestoredSessionIDs: sessionIDs,
}, nil
}
func (s *Store) PurgeDeletedSessionTrees(ctx context.Context, input PurgeDeletedSessionTreesInput) (PurgeDeletedSessionTreesResult, error) {
if s == nil || s.db == nil {
return PurgeDeletedSessionTreesResult{}, errors.New("workspace database is not initialized")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return PurgeDeletedSessionTreesResult{}, fmt.Errorf("begin purge deleted workspace agent session trees: %w", err)
}
defer func() { _ = tx.Rollback() }()
result, err := s.PurgeDeletedSessionTreesTx(ctx, tx, input)
if err != nil {
return PurgeDeletedSessionTreesResult{}, err
}
if err := tx.Commit(); err != nil {
return PurgeDeletedSessionTreesResult{}, fmt.Errorf("commit purge deleted workspace agent session trees: %w", err)
}
return result, nil
}
// PurgeDeletedSessionTreesTx permanently removes the selected topmost deleted
// components using tx. The caller owns commit and rollback. This seam lets a
// workspace store remove product-side state in the same transaction as the
// canonical agent rows. RootSessionIDs is retained as a compatibility field;
// its values identify component anchors and may name root or child Sessions.
func (s *Store) PurgeDeletedSessionTreesTx(
ctx context.Context,
tx *sql.Tx,
input PurgeDeletedSessionTreesInput,
) (PurgeDeletedSessionTreesResult, error) {
if s == nil || tx == nil {
return PurgeDeletedSessionTreesResult{}, errors.New("workspace database transaction is not initialized")
}
workspaceID := strings.TrimSpace(input.WorkspaceID)
if workspaceID == "" {
return PurgeDeletedSessionTreesResult{}, ErrDeletedSessionNotFound
}
rootIDs := normalizedSessionIDs(input.RootSessionIDs)
if len(rootIDs) == 0 {
rows, err := tx.QueryContext(ctx, `
SELECT anchor.agent_session_id
FROM workspace_agent_sessions anchor
WHERE anchor.workspace_id = ? AND anchor.deleted_at_unix_ms > 0
AND NOT EXISTS (
SELECT 1 FROM workspace_agent_sessions parent
WHERE parent.workspace_id = anchor.workspace_id
AND parent.agent_session_id = anchor.parent_agent_session_id
AND parent.deleted_at_unix_ms > 0
)
ORDER BY anchor.agent_session_id ASC
`, workspaceID)
if err != nil {
return PurgeDeletedSessionTreesResult{}, fmt.Errorf("list deleted workspace agent session roots for purge: %w", err)
}
for rows.Next() {
var rootID string
if err := rows.Scan(&rootID); err != nil {
rows.Close()
return PurgeDeletedSessionTreesResult{}, fmt.Errorf("scan deleted workspace agent session root for purge: %w", err)
}
rootIDs = append(rootIDs, strings.TrimSpace(rootID))
}
if err := rows.Err(); err != nil {
rows.Close()
return PurgeDeletedSessionTreesResult{}, fmt.Errorf("iterate deleted workspace agent session roots for purge: %w", err)
}
if err := rows.Close(); err != nil {
return PurgeDeletedSessionTreesResult{}, fmt.Errorf("close deleted workspace agent session roots for purge: %w", err)
}
}
result := PurgeDeletedSessionTreesResult{
PurgedRootSessionIDs: make([]string, 0, len(rootIDs)),
PurgedSessionIDs: make([]string, 0),
}
for _, rootID := range rootIDs {
candidates, found, err := deletedTreePurgeCandidatesTx(ctx, tx, workspaceID, rootID)
if err != nil {
return PurgeDeletedSessionTreesResult{}, err
}
if !found {
continue
}
for _, candidate := range candidates {
removedMessages, removed, err := purgeDeletedSessionTx(ctx, tx, candidate)
if err != nil {
return PurgeDeletedSessionTreesResult{}, err
}
if !removed {
return PurgeDeletedSessionTreesResult{}, fmt.Errorf("%w: %s", ErrDeletedSessionNotRestorable, DeletedSessionUnavailableIncompleteTree)
}
result.PurgedSessionIDs = append(result.PurgedSessionIDs, candidate.AgentSessionID)
result.RemovedSessions++
result.RemovedMessages += removedMessages
result.PayloadBytes += candidate.PayloadBytes
}
result.PurgedRootSessionIDs = append(result.PurgedRootSessionIDs, rootID)
}
sort.Strings(result.PurgedRootSessionIDs)
sort.Strings(result.PurgedSessionIDs)
return result, nil
}
func deletedTreePurgeCandidatesTx(ctx context.Context, tx *sql.Tx, workspaceID, componentID string) ([]PurgedSession, bool, error) {
var componentDeletedAt int64
err := tx.QueryRowContext(ctx, `
SELECT anchor.deleted_at_unix_ms
FROM workspace_agent_sessions anchor
WHERE anchor.workspace_id = ? AND anchor.agent_session_id = ?
AND anchor.deleted_at_unix_ms > 0
AND NOT EXISTS (
SELECT 1 FROM workspace_agent_sessions parent
WHERE parent.workspace_id = anchor.workspace_id
AND parent.agent_session_id = anchor.parent_agent_session_id
AND parent.deleted_at_unix_ms > 0
)
`, workspaceID, componentID).Scan(&componentDeletedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("read deleted workspace agent session component for purge: %w", err)
}
rows, err := tx.QueryContext(ctx, `
WITH RECURSIVE tree(agent_session_id, depth) AS (
SELECT anchor.agent_session_id, 0
FROM workspace_agent_sessions anchor
WHERE anchor.workspace_id = ? AND anchor.agent_session_id = ?
UNION
SELECT child.agent_session_id, parent.depth + 1
FROM workspace_agent_sessions child
JOIN tree parent ON child.parent_agent_session_id = parent.agent_session_id
WHERE child.workspace_id = ? AND child.session_kind = 'child'
)
SELECT s.agent_session_id, s.deleted_at_unix_ms, tree.depth,
COALESCE((
SELECT SUM(length(CAST(m.payload_json AS BLOB)) + length(CAST(m.semantics_json AS BLOB)))
FROM workspace_agent_messages m
WHERE m.workspace_id = s.workspace_id AND m.agent_session_id = s.agent_session_id
), 0)
FROM tree
JOIN workspace_agent_sessions s
ON s.workspace_id = ? AND s.agent_session_id = tree.agent_session_id
ORDER BY tree.depth DESC, s.agent_session_id ASC
`, workspaceID, componentID, workspaceID, workspaceID)
if err != nil {
return nil, false, fmt.Errorf("resolve deleted workspace agent session tree for purge: %w", err)
}
defer rows.Close()
candidates := make([]PurgedSession, 0)
for rows.Next() {
var candidate PurgedSession
var depth int64
candidate.WorkspaceID = workspaceID
if err := rows.Scan(
&candidate.AgentSessionID, &candidate.DeletedAtUnixMS,
&depth, &candidate.PayloadBytes,
); err != nil {
return nil, false, fmt.Errorf("scan deleted workspace agent session tree for purge: %w", err)
}
if candidate.DeletedAtUnixMS <= 0 {
return nil, false, fmt.Errorf("%w: %s", ErrDeletedSessionNotRestorable, DeletedSessionUnavailableIncompleteTree)
}
candidates = append(candidates, candidate)
}
if err := rows.Err(); err != nil {
return nil, false, fmt.Errorf("iterate deleted workspace agent session tree for purge: %w", err)
}
if len(candidates) == 0 {
return nil, false, nil
}
return candidates, true, nil
}
func (s *Store) ListRecoverableDeletedSessionResources(ctx context.Context) ([]DeletedSessionResource, error) {
if s == nil || s.db == nil {
return nil, errors.New("workspace database is not initialized")
}
rows, err := s.db.QueryContext(ctx, `
SELECT workspace_id, agent_session_id, cwd
FROM workspace_agent_sessions
WHERE deleted_at_unix_ms > 0 AND recoverable_delete_version = ?
ORDER BY workspace_id ASC, agent_session_id ASC
`, recoverableDeleteVersionCurrent)
if err != nil {
return nil, fmt.Errorf("list recoverable deleted workspace agent session resources: %w", err)
}
defer rows.Close()
resources := make([]DeletedSessionResource, 0)
for rows.Next() {
var resource DeletedSessionResource
if err := rows.Scan(&resource.WorkspaceID, &resource.AgentSessionID, &resource.Cwd); err != nil {
return nil, fmt.Errorf("scan recoverable deleted workspace agent session resource: %w", err)
}
resources = append(resources, resource)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate recoverable deleted workspace agent session resources: %w", err)
}
return resources, nil
}