Files
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

530 lines
18 KiB
Go

package workspace
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
userprojectbiz "github.com/tutti-os/tutti/services/tuttid/biz/userproject"
)
func (s *SQLiteStore) ListUserProjects(ctx context.Context) ([]userprojectbiz.Project, error) {
if s == nil || s.writeDB == nil {
return nil, errors.New("workspace database is not initialized")
}
rows, err := s.readDB.QueryContext(ctx, `
SELECT id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms, pinned_at_unix_ms, sort_order
FROM user_projects
ORDER BY sort_order ASC, id ASC
`)
if err != nil {
return nil, fmt.Errorf("list user projects: %w", err)
}
defer rows.Close()
var result []userprojectbiz.Project
for rows.Next() {
var project userprojectbiz.Project
if err := rows.Scan(
&project.ID,
&project.Path,
&project.Label,
&project.CreatedAtUnixMS,
&project.UpdatedAtUnixMS,
&project.LastUsedAtUnixMS,
&project.PinnedAtUnixMS,
&project.SortOrder,
); err != nil {
return nil, fmt.Errorf("scan user project: %w", err)
}
project.SectionKey = userprojectbiz.SectionKeyFromPath(project.Path)
result = append(result, project)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate user projects: %w", err)
}
return result, nil
}
func (s *SQLiteStore) DeleteUserProject(ctx context.Context, id string) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin delete user project: %w", err)
}
defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, `
DELETE FROM user_projects
WHERE id = ?
`, id)
if err != nil {
return fmt.Errorf("delete user project: %w", err)
}
if err := compactUserProjectOrder(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete user project: %w", err)
}
return nil
}
// DeleteUserProjectByPath removes a user project by its stored path rather
// than by a recomputed id. The path column remains the durable lookup key, with
// a platform-aware identity fallback so Windows slash/case variants resolve
// to the same stored row.
func (s *SQLiteStore) DeleteUserProjectByPath(ctx context.Context, path string) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin delete user project by path: %w", err)
}
defer func() { _ = tx.Rollback() }()
storedPath, found, err := findUserProjectPathTx(ctx, tx, path)
if err != nil {
return fmt.Errorf("find user project by path before delete: %w", err)
}
if !found {
if err := compactUserProjectOrder(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete user project by path: %w", err)
}
return nil
}
_, err = tx.ExecContext(ctx, `
DELETE FROM user_projects
WHERE path = ?
`, storedPath)
if err != nil {
return fmt.Errorf("delete user project by path: %w", err)
}
if err := compactUserProjectOrder(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete user project by path: %w", err)
}
return nil
}
// TryFinalizeUserProjectRemovalByPath atomically checks whether any live,
// unpinned root sessions still belong to the project and, only when none do,
// rehomes every remaining project session row to Chats before deleting the
// project metadata. Remaining rows are pinned live trees or recoverable
// tombstones; rehoming tombstones also makes a later restore land in Chats.
//
// The check and mutation share the writer transaction. Session creation also
// classifies rail membership through this database, so callers can delete the
// returned roots through Agent Host and retry without a check/delete race.
func (s *SQLiteStore) TryFinalizeUserProjectRemovalByPath(ctx context.Context, path string) (UserProjectRemovalPlan, error) {
if s == nil || s.writeDB == nil {
return UserProjectRemovalPlan{}, errors.New("workspace database is not initialized")
}
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return UserProjectRemovalPlan{}, fmt.Errorf("begin finalize user project removal: %w", err)
}
defer func() { _ = tx.Rollback() }()
storedPath, found, err := findUserProjectPathTx(ctx, tx, path)
if err != nil {
return UserProjectRemovalPlan{}, fmt.Errorf("find user project before finalize removal: %w", err)
}
if !found {
return UserProjectRemovalPlan{Finalized: true}, nil
}
sectionKey := agentactivitybiz.RailSectionKeyForProject(storedPath)
rows, err := tx.QueryContext(ctx, `
SELECT workspace_id, agent_session_id
FROM workspace_agent_sessions
WHERE session_kind = 'root'
AND rail_section_key = ?
AND pinned_at_unix_ms = 0
AND deleted_at_unix_ms = 0
ORDER BY workspace_id ASC, agent_session_id ASC
`, sectionKey)
if err != nil {
return UserProjectRemovalPlan{}, fmt.Errorf("list unpinned project sessions before removal: %w", err)
}
pending := make(map[string][]string)
for rows.Next() {
var workspaceID, sessionID string
if err := rows.Scan(&workspaceID, &sessionID); err != nil {
_ = rows.Close()
return UserProjectRemovalPlan{}, fmt.Errorf("scan unpinned project session before removal: %w", err)
}
workspaceID = strings.TrimSpace(workspaceID)
sessionID = strings.TrimSpace(sessionID)
if workspaceID != "" && sessionID != "" {
pending[workspaceID] = append(pending[workspaceID], sessionID)
}
}
if err := rows.Close(); err != nil {
return UserProjectRemovalPlan{}, fmt.Errorf("close unpinned project sessions before removal: %w", err)
}
if err := rows.Err(); err != nil {
return UserProjectRemovalPlan{}, fmt.Errorf("iterate unpinned project sessions before removal: %w", err)
}
if len(pending) > 0 {
return UserProjectRemovalPlan{SessionIDsByWorkspace: pending}, nil
}
rehomed, err := tx.ExecContext(ctx, `
UPDATE workspace_agent_sessions
SET rail_section_kind = ?, rail_project_path = '', rail_section_key = ?
WHERE rail_section_key = ?
`, agentactivitybiz.RailSectionKindConversations, agentactivitybiz.RailSectionKeyConversations, sectionKey)
if err != nil {
return UserProjectRemovalPlan{}, fmt.Errorf("rehome retained project sessions to Chats: %w", err)
}
rehomedCount, err := rehomed.RowsAffected()
if err != nil {
return UserProjectRemovalPlan{}, fmt.Errorf("count rehomed project sessions: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM user_projects WHERE path = ?`, storedPath); err != nil {
return UserProjectRemovalPlan{}, fmt.Errorf("delete finalized user project by path: %w", err)
}
if err := compactUserProjectOrder(ctx, tx); err != nil {
return UserProjectRemovalPlan{}, err
}
if err := tx.Commit(); err != nil {
return UserProjectRemovalPlan{}, fmt.Errorf("commit finalized user project removal: %w", err)
}
return UserProjectRemovalPlan{Finalized: true, RehomedSessions: int(rehomedCount)}, nil
}
func (s *SQLiteStore) PutUserProject(ctx context.Context, project userprojectbiz.Project) (userprojectbiz.Project, error) {
if s == nil || s.writeDB == nil {
return userprojectbiz.Project{}, errors.New("workspace database is not initialized")
}
now := unixMs(time.Now().UTC())
if project.CreatedAtUnixMS <= 0 {
project.CreatedAtUnixMS = now
}
if project.LastUsedAtUnixMS <= 0 {
project.LastUsedAtUnixMS = now
}
project.UpdatedAtUnixMS = project.LastUsedAtUnixMS
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return userprojectbiz.Project{}, fmt.Errorf("begin put user project: %w", err)
}
defer func() { _ = tx.Rollback() }()
existingPath, found, err := findUserProjectPathTx(ctx, tx, project.Path)
if err != nil {
return userprojectbiz.Project{}, fmt.Errorf("find user project before put: %w", err)
}
if found {
// Preserve the first stored spelling while treating Windows path
// variants as one project identity. The UNIQUE(path) constraint then
// remains the final write guard without a schema change.
project.Path = existingPath
}
if !found {
var firstUnpinnedOrder int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_projects WHERE pinned_at_unix_ms > 0`).Scan(&firstUnpinnedOrder); err != nil {
return userprojectbiz.Project{}, fmt.Errorf("find first unpinned user project order: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE user_projects SET sort_order = sort_order + 1 WHERE sort_order >= ?`, firstUnpinnedOrder); err != nil {
return userprojectbiz.Project{}, fmt.Errorf("shift user projects before insert: %w", err)
}
project.SortOrder = firstUnpinnedOrder
project.PinnedAtUnixMS = 0
}
_, err = tx.ExecContext(ctx, `
INSERT INTO user_projects (
id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms, pinned_at_unix_ms, sort_order
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
label = excluded.label,
updated_at_unix_ms = excluded.updated_at_unix_ms,
last_used_at_unix_ms = excluded.last_used_at_unix_ms
`, project.ID, project.Path, project.Label, project.CreatedAtUnixMS, project.UpdatedAtUnixMS, project.LastUsedAtUnixMS, project.PinnedAtUnixMS, project.SortOrder)
if err != nil {
return userprojectbiz.Project{}, fmt.Errorf("put user project: %w", err)
}
row := tx.QueryRowContext(ctx, `
SELECT id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms, pinned_at_unix_ms, sort_order
FROM user_projects
WHERE path = ?
`, project.Path)
var stored userprojectbiz.Project
if err := row.Scan(
&stored.ID,
&stored.Path,
&stored.Label,
&stored.CreatedAtUnixMS,
&stored.UpdatedAtUnixMS,
&stored.LastUsedAtUnixMS,
&stored.PinnedAtUnixMS,
&stored.SortOrder,
); err != nil {
return userprojectbiz.Project{}, fmt.Errorf("get user project after put: %w", err)
}
stored.SectionKey = userprojectbiz.SectionKeyFromPath(stored.Path)
if _, err := s.agentStore().RepairImportedProjectRailSectionsTx(ctx, tx, stored.Path); err != nil {
return userprojectbiz.Project{}, fmt.Errorf("repair imported project rail after put: %w", err)
}
if err := tx.Commit(); err != nil {
return userprojectbiz.Project{}, fmt.Errorf("commit put user project: %w", err)
}
return stored, nil
}
// findUserProjectPathTx first uses the UNIQUE column directly, then applies
// the platform path identity rules for a Windows slash/case variant. The
// fallback scan is intentionally limited to project registration/deletion,
// which are low-frequency mutations; rail reads keep their indexed section
// key queries.
func findUserProjectPathTx(ctx context.Context, tx *sql.Tx, path string) (string, bool, error) {
row := tx.QueryRowContext(ctx, `SELECT path FROM user_projects WHERE path = ?`, path)
var storedPath string
if err := row.Scan(&storedPath); err == nil {
return storedPath, true, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return "", false, fmt.Errorf("find exact user project path: %w", err)
}
rows, err := tx.QueryContext(ctx, `SELECT path FROM user_projects`)
if err != nil {
return "", false, fmt.Errorf("list user project paths for identity match: %w", err)
}
defer rows.Close()
for rows.Next() {
if err := rows.Scan(&storedPath); err != nil {
return "", false, fmt.Errorf("scan user project path for identity match: %w", err)
}
if agentactivitybiz.AreProjectPathsEqual(path, storedPath) {
return storedPath, true, nil
}
}
if err := rows.Err(); err != nil {
return "", false, fmt.Errorf("iterate user project paths for identity match: %w", err)
}
return "", false, nil
}
func (s *SQLiteStore) TouchUserProject(ctx context.Context, id string, atUnixMS int64) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
if atUnixMS <= 0 {
atUnixMS = unixMs(time.Now().UTC())
}
_, err := s.writeDB.ExecContext(ctx, `
UPDATE user_projects
SET last_used_at_unix_ms = ?, updated_at_unix_ms = ?
WHERE id = ?
`, atUnixMS, atUnixMS, id)
if err != nil {
return fmt.Errorf("touch user project: %w", err)
}
return nil
}
func (s *SQLiteStore) MoveUserProject(ctx context.Context, projectID string, beforeProjectID *string) ([]userprojectbiz.Project, error) {
if s == nil || s.writeDB == nil {
return nil, errors.New("workspace database is not initialized")
}
projectID = strings.TrimSpace(projectID)
if projectID == "" {
return nil, ErrUserProjectNotFound
}
var beforeID *string
if beforeProjectID != nil {
normalized := strings.TrimSpace(*beforeProjectID)
if normalized == "" {
return nil, ErrUserProjectNotFound
}
beforeID = &normalized
}
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin move user project: %w", err)
}
defer func() { _ = tx.Rollback() }()
projects, err := listUserProjectsTx(ctx, tx)
if err != nil {
return nil, err
}
fromIndex := -1
beforeIndex := -1
for index, project := range projects {
if project.ID == projectID {
fromIndex = index
}
if beforeID != nil && project.ID == *beforeID {
beforeIndex = index
}
}
if fromIndex < 0 || (beforeID != nil && beforeIndex < 0) {
return nil, ErrUserProjectNotFound
}
if beforeID != nil && (projects[fromIndex].PinnedAtUnixMS > 0) != (projects[beforeIndex].PinnedAtUnixMS > 0) {
return nil, ErrUserProjectPartitionMismatch
}
if beforeID == nil || *beforeID != projectID {
moving := projects[fromIndex]
projects = append(projects[:fromIndex], projects[fromIndex+1:]...)
insertIndex := len(projects)
if beforeID != nil {
for index, project := range projects {
if project.ID == *beforeID {
insertIndex = index
break
}
}
} else if moving.PinnedAtUnixMS > 0 {
insertIndex = 0
for insertIndex < len(projects) && projects[insertIndex].PinnedAtUnixMS > 0 {
insertIndex++
}
}
projects = append(projects, userprojectbiz.Project{})
copy(projects[insertIndex+1:], projects[insertIndex:])
projects[insertIndex] = moving
}
if err := rewriteUserProjectOrder(ctx, tx, projects); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit move user project: %w", err)
}
return projects, nil
}
func (s *SQLiteStore) PinUserProject(ctx context.Context, projectID string, pinned bool) ([]userprojectbiz.Project, bool, error) {
if s == nil || s.writeDB == nil {
return nil, false, errors.New("workspace database is not initialized")
}
projectID = strings.TrimSpace(projectID)
if projectID == "" {
return nil, false, ErrUserProjectNotFound
}
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return nil, false, fmt.Errorf("begin pin user project: %w", err)
}
defer func() { _ = tx.Rollback() }()
projects, err := listUserProjectsTx(ctx, tx)
if err != nil {
return nil, false, err
}
fromIndex := -1
for index := range projects {
if projects[index].ID == projectID {
fromIndex = index
break
}
}
if fromIndex < 0 {
return nil, false, ErrUserProjectNotFound
}
if (projects[fromIndex].PinnedAtUnixMS > 0) == pinned {
if err := tx.Commit(); err != nil {
return nil, false, fmt.Errorf("commit idempotent pin user project: %w", err)
}
return projects, false, nil
}
now := unixMs(time.Now().UTC())
pinnedAtUnixMS := int64(0)
if pinned {
pinnedAtUnixMS = now
}
moving := projects[fromIndex]
moving.PinnedAtUnixMS = pinnedAtUnixMS
moving.UpdatedAtUnixMS = now
projects = append(projects[:fromIndex], projects[fromIndex+1:]...)
insertIndex := 0
if !pinned {
for insertIndex < len(projects) && projects[insertIndex].PinnedAtUnixMS > 0 {
insertIndex++
}
}
projects = append(projects, userprojectbiz.Project{})
copy(projects[insertIndex+1:], projects[insertIndex:])
projects[insertIndex] = moving
if _, err := tx.ExecContext(ctx, `
UPDATE user_projects
SET pinned_at_unix_ms = ?, updated_at_unix_ms = ?
WHERE id = ?
`, pinnedAtUnixMS, now, projectID); err != nil {
return nil, false, fmt.Errorf("update user project pinned state: %w", err)
}
if err := rewriteUserProjectOrder(ctx, tx, projects); err != nil {
return nil, false, err
}
if err := tx.Commit(); err != nil {
return nil, false, fmt.Errorf("commit pin user project: %w", err)
}
return projects, true, nil
}
func rewriteUserProjectOrder(ctx context.Context, tx *sql.Tx, projects []userprojectbiz.Project) error {
for index := range projects {
projects[index].SortOrder = index
if _, err := tx.ExecContext(ctx, `UPDATE user_projects SET sort_order = ? WHERE id = ?`, index, projects[index].ID); err != nil {
return fmt.Errorf("rewrite user project order: %w", err)
}
}
return nil
}
func compactUserProjectOrder(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY sort_order ASC, id ASC) - 1 AS next_sort_order
FROM user_projects
)
UPDATE user_projects
SET sort_order = (SELECT next_sort_order FROM ordered WHERE ordered.id = user_projects.id)
`)
if err != nil {
return fmt.Errorf("compact user project order: %w", err)
}
return nil
}
func listUserProjectsTx(ctx context.Context, tx *sql.Tx) ([]userprojectbiz.Project, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms, pinned_at_unix_ms, sort_order
FROM user_projects
ORDER BY sort_order ASC, id ASC
`)
if err != nil {
return nil, fmt.Errorf("list user projects in transaction: %w", err)
}
defer rows.Close()
projects := make([]userprojectbiz.Project, 0)
for rows.Next() {
var project userprojectbiz.Project
if err := rows.Scan(&project.ID, &project.Path, &project.Label, &project.CreatedAtUnixMS, &project.UpdatedAtUnixMS, &project.LastUsedAtUnixMS, &project.PinnedAtUnixMS, &project.SortOrder); err != nil {
return nil, fmt.Errorf("scan user project in transaction: %w", err)
}
project.SectionKey = userprojectbiz.SectionKeyFromPath(project.Path)
projects = append(projects, project)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate user projects in transaction: %w", err)
}
return projects, nil
}