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

502 lines
14 KiB
Go

package workspace
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
agentstore "github.com/tutti-os/tutti/packages/agent/store-sqlite"
workspaceissues "github.com/tutti-os/tutti/packages/workspace/issues"
workspacebiz "github.com/tutti-os/tutti/services/tuttid/biz/workspace"
tuttitypes "github.com/tutti-os/tutti/services/tuttid/types"
sqlitedriver "modernc.org/sqlite"
)
const defaultSQLiteBusyTimeoutMillisec = 5000
const defaultSQLiteReaderConnections = 4
const sqliteIOErrTruncate = 1546
var sqliteWALRetryDelays = []time.Duration{100 * time.Millisecond, 300 * time.Millisecond}
type SQLiteStore struct {
dbPath string
writeDB *sql.DB
readDB *sql.DB
agentWriter *agentstore.Store
agentReader *agentstore.Store
sourceActivityRowsHook func(tuttiModeSourceActivityRows) tuttiModeSourceActivityRows
}
type sqliteOnlineBackuper interface {
NewBackup(string) (*sqlitedriver.Backup, error)
}
type sqlitePragmaExecutor interface {
Exec(string, ...any) (sql.Result, error)
}
type sqliteErrorCoder interface {
Code() int
}
func OpenSQLiteStore(dbPath string) (*SQLiteStore, error) {
dbPath = strings.TrimSpace(dbPath)
if dbPath == "" {
return nil, errors.New("workspace database path is empty")
}
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return nil, fmt.Errorf("create tutti database directory: %w", err)
}
db, err := sql.Open("sqlite", sqliteWriterDSN(dbPath))
if err != nil {
return nil, fmt.Errorf("open tutti database: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
store := &SQLiteStore{dbPath: dbPath, writeDB: db}
store.agentWriter = newAgentStore(db)
if err := enableSQLiteWAL(db, time.Sleep); err != nil {
_ = store.Close()
return nil, fmt.Errorf("enable sqlite wal mode: %w", err)
}
return store, nil
}
func enableSQLiteWAL(executor sqlitePragmaExecutor, sleep func(time.Duration)) error {
for attempt := 0; ; attempt++ {
if _, err := executor.Exec("PRAGMA journal_mode = WAL"); err != nil {
if attempt >= len(sqliteWALRetryDelays) || !isSQLiteIOErrTruncate(err) {
return err
}
delay := sqliteWALRetryDelays[attempt]
slog.Warn("retrying sqlite wal mode after transient truncate error",
"event", "workspace.sqlite.wal_retry",
"attempt", attempt+1,
"retry_in_ms", delay.Milliseconds(),
"sqlite_error_code", sqliteIOErrTruncate,
"error", err)
sleep(delay)
continue
}
return nil
}
}
func isSQLiteIOErrTruncate(err error) bool {
var coded sqliteErrorCoder
return errors.As(err, &coded) && coded.Code() == sqliteIOErrTruncate
}
func DefaultDBPath() string {
return tuttitypes.TuttidDBPath()
}
func (s *SQLiteStore) Close() error {
if s == nil {
return nil
}
var readErr error
if s.readDB != nil {
readErr = s.readDB.Close()
}
var writeErr error
if s.writeDB != nil {
writeErr = s.writeDB.Close()
}
return errors.Join(readErr, writeErr)
}
func (s *SQLiteStore) BackupTo(ctx context.Context, destination string) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
destination = filepath.Clean(strings.TrimSpace(destination))
if destination == "." || destination == "" {
return errors.New("workspace database backup destination is empty")
}
if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil {
return fmt.Errorf("create workspace database backup directory: %w", err)
}
if err := os.Remove(destination); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("replace workspace database backup: %w", err)
}
connection, err := s.writeDB.Conn(ctx)
if err != nil {
return fmt.Errorf("open workspace database backup connection: %w", err)
}
defer func() { _ = connection.Close() }()
if err := connection.Raw(func(driverConnection any) error {
backuper, ok := driverConnection.(sqliteOnlineBackuper)
if !ok {
return errors.New("sqlite online backup is unavailable")
}
backup, err := backuper.NewBackup(destination)
if err != nil {
return err
}
more := true
for more {
if err := ctx.Err(); err != nil {
_ = backup.Finish()
return err
}
more, err = backup.Step(256)
if err != nil {
_ = backup.Finish()
return err
}
}
return backup.Finish()
}); err != nil {
_ = os.Remove(destination)
return fmt.Errorf("backup workspace database: %w", err)
}
if err := os.Chmod(destination, 0o600); err != nil {
return fmt.Errorf("protect workspace database backup: %w", err)
}
return nil
}
func (s *SQLiteStore) DatabaseSizeBytes() (int64, error) {
if s == nil || strings.TrimSpace(s.dbPath) == "" {
return 0, errors.New("workspace database is not initialized")
}
info, err := os.Stat(s.dbPath)
if err != nil {
return 0, fmt.Errorf("stat workspace database: %w", err)
}
return info.Size(), nil
}
func sqliteWriterDSN(dbPath string) string {
return sqliteDSN(dbPath, false)
}
func sqliteReaderDSN(dbPath string) string {
return sqliteDSN(dbPath, true)
}
func sqliteDSN(dbPath string, readOnly bool) string {
query := url.Values{}
query.Add("_pragma", fmt.Sprintf("busy_timeout(%d)", defaultSQLiteBusyTimeoutMillisec))
query.Add("_pragma", "foreign_keys(1)")
if readOnly {
query.Set("mode", "ro")
query.Add("_pragma", "query_only(1)")
}
return (&url.URL{Scheme: "file", Path: sqliteDSNPath(dbPath, runtime.GOOS), RawQuery: query.Encode()}).String()
}
func sqliteDSNPath(dbPath string, goos string) string {
if goos != "windows" {
return dbPath
}
path := strings.ReplaceAll(dbPath, `\`, "/")
if len(path) >= 2 && path[1] == ':' && !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func (s *SQLiteStore) openReadPool(ctx context.Context) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
if s.readDB != nil {
return nil
}
db, err := sql.Open("sqlite", sqliteReaderDSN(s.dbPath))
if err != nil {
return fmt.Errorf("open tutti database read pool: %w", err)
}
db.SetMaxOpenConns(defaultSQLiteReaderConnections)
db.SetMaxIdleConns(defaultSQLiteReaderConnections)
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return fmt.Errorf("ping tutti database read pool: %w", err)
}
s.readDB = db
s.agentReader = newAgentStore(db)
return nil
}
func (s *SQLiteStore) Create(ctx context.Context, item workspacebiz.Summary) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
id := strings.TrimSpace(item.ID)
name := strings.TrimSpace(item.Name)
if id == "" || name == "" {
return errors.New("workspace id and name are required")
}
now := unixMs(time.Now().UTC())
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin create workspace: %w", err)
}
defer func() {
_ = tx.Rollback()
}()
_, err = tx.ExecContext(ctx, `
INSERT INTO workspaces (id, name, created_at_unix_ms, updated_at_unix_ms, last_opened_at_unix_ms)
VALUES (?, ?, ?, ?, NULL)
`, id, name, now, now)
if err != nil {
return fmt.Errorf("create workspace: %w", err)
}
_, err = tx.ExecContext(ctx, `
INSERT INTO workspace_issue_topics (
topic_id, workspace_id, title, summary, is_default, pinned_at_unix_ms,
last_activity_at_unix_ms, created_at_unix_ms, updated_at_unix_ms
) VALUES (?, ?, ?, '', 1, 0, ?, ?, ?)
`, workspaceissues.DefaultTopicID, id, workspaceissues.DefaultTopicID, now, now, now)
if err != nil {
return fmt.Errorf("create default workspace issue topic: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit create workspace: %w", err)
}
return nil
}
func (s *SQLiteStore) Delete(ctx context.Context, workspaceID string) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return errors.New("workspace id is required")
}
tx, err := s.writeDB.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin delete workspace: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
result, err := tx.ExecContext(ctx, `
DELETE FROM workspaces
WHERE id = ?
`, workspaceID)
if err != nil {
return fmt.Errorf("delete workspace: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("delete workspace rows affected: %w", err)
}
if rowsAffected == 0 {
return ErrWorkspaceNotFound
}
// Agent activity tables no longer carry a foreign key into workspaces on
// fresh schemas; cascade the deletion explicitly through the agent store
// inside the same transaction so a failure leaves no orphaned agent rows.
if _, err := s.agentStore().ClearSessionsTx(ctx, tx, workspaceID); err != nil {
return fmt.Errorf("clear agent sessions for deleted workspace: %w", err)
}
// Older Tutti-mode schemas predate the workspace foreign key on immutable
// turn snapshots. Keep deletion explicit so upgraded databases cannot retain
// orphaned reservations.
if _, err := tx.ExecContext(ctx, `DELETE FROM tutti_mode_turn_snapshots WHERE workspace_id = ?`, workspaceID); err != nil {
return fmt.Errorf("clear Tutti mode turn snapshots for deleted workspace: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete workspace: %w", err)
}
committed = true
return nil
}
func (s *SQLiteStore) Get(ctx context.Context, workspaceID string) (workspacebiz.Summary, error) {
if s == nil || s.writeDB == nil {
return workspacebiz.Summary{}, errors.New("workspace database is not initialized")
}
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return workspacebiz.Summary{}, errors.New("workspace id is required")
}
row := s.readDB.QueryRowContext(ctx, `
SELECT id, name
, last_opened_at_unix_ms
FROM workspaces
WHERE id = ?
`, workspaceID)
var item workspacebiz.Summary
var lastOpenedAt sql.NullInt64
if err := row.Scan(&item.ID, &item.Name, &lastOpenedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return workspacebiz.Summary{}, ErrWorkspaceNotFound
}
return workspacebiz.Summary{}, fmt.Errorf("get workspace: %w", err)
}
item.LastOpenedAt = nullableUnixMs(lastOpenedAt)
return item, nil
}
func (s *SQLiteStore) List(ctx context.Context) ([]workspacebiz.Summary, error) {
if s == nil || s.writeDB == nil {
return nil, errors.New("workspace database is not initialized")
}
rows, err := s.readDB.QueryContext(ctx, `
SELECT id, name, last_opened_at_unix_ms
FROM workspaces
ORDER BY COALESCE(last_opened_at_unix_ms, 0) DESC, updated_at_unix_ms DESC, id ASC`)
if err != nil {
return nil, fmt.Errorf("list workspaces: %w", err)
}
defer rows.Close()
items := make([]workspacebiz.Summary, 0)
for rows.Next() {
var item workspacebiz.Summary
var lastOpenedAt sql.NullInt64
if err := rows.Scan(&item.ID, &item.Name, &lastOpenedAt); err != nil {
return nil, fmt.Errorf("scan workspace: %w", err)
}
item.LastOpenedAt = nullableUnixMs(lastOpenedAt)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate workspaces: %w", err)
}
return items, nil
}
func (s *SQLiteStore) GetStartup(ctx context.Context) (*workspacebiz.Summary, error) {
if s == nil || s.writeDB == nil {
return nil, errors.New("workspace database is not initialized")
}
row := s.readDB.QueryRowContext(ctx, `
SELECT id, name, last_opened_at_unix_ms
FROM workspaces
WHERE last_opened_at_unix_ms IS NOT NULL
ORDER BY last_opened_at_unix_ms DESC, updated_at_unix_ms DESC, id ASC
LIMIT 1`)
var item workspacebiz.Summary
var lastOpenedAt sql.NullInt64
if err := row.Scan(&item.ID, &item.Name, &lastOpenedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("get startup workspace: %w", err)
}
item.LastOpenedAt = nullableUnixMs(lastOpenedAt)
return &item, nil
}
func (s *SQLiteStore) Update(ctx context.Context, item workspacebiz.Summary) error {
if s == nil || s.writeDB == nil {
return errors.New("workspace database is not initialized")
}
id := strings.TrimSpace(item.ID)
name := strings.TrimSpace(item.Name)
if id == "" || name == "" {
return errors.New("workspace id and name are required")
}
result, err := s.writeDB.ExecContext(ctx, `
UPDATE workspaces
SET name = ?, updated_at_unix_ms = ?
WHERE id = ?
`, name, unixMs(time.Now().UTC()), id)
if err != nil {
return fmt.Errorf("update workspace: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update workspace rows affected: %w", err)
}
if rowsAffected == 0 {
return ErrWorkspaceNotFound
}
return nil
}
func (s *SQLiteStore) Open(ctx context.Context, workspaceID string) (workspacebiz.Summary, error) {
if s == nil || s.writeDB == nil {
return workspacebiz.Summary{}, errors.New("workspace database is not initialized")
}
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return workspacebiz.Summary{}, errors.New("workspace id is required")
}
now := unixMs(time.Now().UTC())
result, err := s.writeDB.ExecContext(ctx, `
UPDATE workspaces
SET last_opened_at_unix_ms = ?, updated_at_unix_ms = ?
WHERE id = ?
`, now, now, workspaceID)
if err != nil {
return workspacebiz.Summary{}, fmt.Errorf("open workspace: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return workspacebiz.Summary{}, fmt.Errorf("open workspace rows affected: %w", err)
}
if rowsAffected == 0 {
return workspacebiz.Summary{}, ErrWorkspaceNotFound
}
return s.Get(ctx, workspaceID)
}
func nullableUnixMs(value sql.NullInt64) *time.Time {
if !value.Valid {
return nil
}
converted := time.UnixMilli(value.Int64).UTC()
return &converted
}
func unixMs(value time.Time) int64 {
return value.UnixMilli()
}