From 87bf32c0ea372cea5d002e55b5e957301c79b4a2 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:33:35 +0800 Subject: [PATCH 1/6] refactor(storage): share disposable projection lifecycle Problem: Each disposable catalog would otherwise duplicate SQLite migration, integrity, quarantine, permission, and in-memory fallback behavior. Root cause: The original session catalog owned database lifecycle policy together with session-specific schema and reconciliation logic. Fix: Extract the reusable lifecycle into internal/projectiondb, migrate the session catalog to it, and add schema-v3 keyset session pagination for follow-up consumers. Verification: Covered by projectiondb lifecycle tests and session catalog migration, pagination, cursor, and compatibility tests. --- internal/cli/catalogs.go | 107 ++++++ internal/cli/cli.go | 5 +- internal/cli/doctor.go | 3 + internal/projectiondb/projectiondb.go | 400 +++++++++++++++++++++ internal/projectiondb/projectiondb_test.go | 134 +++++++ internal/projectiondb/remote_fs_darwin.go | 28 ++ internal/projectiondb/remote_fs_linux.go | 18 + internal/projectiondb/remote_fs_other.go | 5 + internal/projectiondb/remote_fs_windows.go | 23 ++ internal/sessioncatalog/catalog.go | 116 +----- internal/sessioncatalog/catalog_test.go | 36 +- internal/sessioncatalog/schema.go | 72 ++-- internal/sessioncatalog/session_page.go | 158 ++++++++ internal/sessioncatalog/types.go | 19 +- 14 files changed, 969 insertions(+), 155 deletions(-) create mode 100644 internal/cli/catalogs.go create mode 100644 internal/projectiondb/projectiondb.go create mode 100644 internal/projectiondb/projectiondb_test.go create mode 100644 internal/projectiondb/remote_fs_darwin.go create mode 100644 internal/projectiondb/remote_fs_linux.go create mode 100644 internal/projectiondb/remote_fs_other.go create mode 100644 internal/projectiondb/remote_fs_windows.go create mode 100644 internal/sessioncatalog/session_page.go diff --git a/internal/cli/catalogs.go b/internal/cli/catalogs.go new file mode 100644 index 0000000000..b57278382c --- /dev/null +++ b/internal/cli/catalogs.go @@ -0,0 +1,107 @@ +package cli + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "reasonix/internal/projectiondb" + "reasonix/internal/sessioncatalog" +) + +type catalogInspection struct { + Name string `json:"name"` + Info projectiondb.Inspection `json:"info"` +} + +type catalogCommand struct { + name string + path func() string + reindex func([]string) int +} + +var catalogCommands []catalogCommand + +func registerCatalogCommand(command catalogCommand) { + catalogCommands = append(catalogCommands, command) +} + +func runSessionOrCatalogCommand(command string, args []string) int { + configureCLIThemeFromConfig() + if command != "catalogs" { + return sessionOrSessionsCommand(command, args) + } + return catalogsCommand(args) +} + +func doctorCatalogsCommand(args []string) int { + fs := flag.NewFlagSet("doctor catalogs", flag.ContinueOnError) + jsonOut := fs.Bool("json", false, "print catalog diagnostics as JSON") + if code, ok := parseCommandFlags(fs, args); !ok { + return code + } + if fs.NArg() != 0 { + fmt.Fprintln(os.Stderr, "usage: reasonix doctor catalogs [--json]") + return 2 + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + items := []catalogInspection{ + {Name: "sessions", Info: projectiondb.Inspect(ctx, sessioncatalog.DefaultPath())}, + } + for _, command := range catalogCommands { + items = append(items, catalogInspection{Name: command.name, Info: projectiondb.Inspect(ctx, command.path())}) + } + if *jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(items); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 + } + fmt.Println("Reasonix disposable catalogs") + for _, item := range items { + state := "missing" + if item.Info.Exists && item.Info.Error == "" { + state = item.Info.Integrity + } else if item.Info.Error != "" { + state = "error: " + item.Info.Error + } + fmt.Printf(" %-8s %-12s schema=%d size=%d path=%s\n", item.Name, state, item.Info.Schema, item.Info.Size, item.Info.Path) + } + return 0 +} + +func catalogsCommand(args []string) int { + if len(args) < 2 || args[0] != "reindex" { + fmt.Fprintln(os.Stderr, "usage: reasonix catalogs reindex [options]") + return 2 + } + for _, command := range catalogCommands { + if args[1] == command.name { + return command.reindex(args[2:]) + } + } + fmt.Fprintln(os.Stderr, "unknown catalog:", args[1]) + return 2 +} + +func printCatalogStatus(status any, jsonOut bool) int { + if jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(status); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 + } + b, _ := json.Marshal(status) + fmt.Println(string(b)) + return 0 +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 6148e25928..08a7b34a64 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -164,9 +164,8 @@ func RunWithBuildInfo(args []string, info BuildInfo) int { case "report": configureCLIThemeFromConfig() return reportCommand(rest) - case "session", "sessions": - configureCLIThemeFromConfig() - return sessionOrSessionsCommand(cmd, rest) + case "session", "sessions", "catalogs": + return runSessionOrCatalogCommand(cmd, rest) case "hook", "hooks": configureCLIThemeFromConfig() return hookCommand(rest) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 62415a37f1..70632e66d8 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -14,6 +14,9 @@ import ( ) func doctorCommand(args []string, version string) int { + if len(args) > 0 && args[0] == "catalogs" { + return doctorCatalogsCommand(args[1:]) + } if len(args) > 0 && args[0] == "sessions" { return doctorSessionsCommand(args[1:]) } diff --git a/internal/projectiondb/projectiondb.go b/internal/projectiondb/projectiondb.go new file mode 100644 index 0000000000..518d6071fc --- /dev/null +++ b/internal/projectiondb/projectiondb.go @@ -0,0 +1,400 @@ +// Package projectiondb owns the lifecycle of disposable SQLite projections. +// Business data must remain authoritative outside the database: callers are +// expected to be able to discard and rebuild every database opened here. +package projectiondb + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +type Mode string + +const ( + ModeDisk Mode = "disk" + ModeMemory Mode = "memory" +) + +type State string + +const ( + StateReady State = "ready" + StateDegraded State = "degraded" +) + +type Status struct { + State State `json:"state"` + Mode Mode `json:"mode"` + Path string `json:"path,omitempty"` + Revision uint64 `json:"revision"` + Indexed int64 `json:"indexed"` + Total int64 `json:"total"` + Pending int64 `json:"pending"` + Failed int64 `json:"failed"` + LastError string `json:"lastError,omitempty"` + QuarantinedPath string `json:"quarantinedPath,omitempty"` +} + +type Migration struct { + Version int + Apply func(context.Context, *sql.Tx) error +} + +type OpenOptions struct { + Path string + MemoryName string + Migrations []Migration + InMemory bool + MaxOpenConns int + Now func() time.Time + SecureDelete bool + AutoVacuum bool +} + +type Handle struct { + DB *sql.DB + Status Status +} + +type FutureSchemaError struct { + Found int + Supported int +} + +func (e *FutureSchemaError) Error() string { + return fmt.Sprintf("projection schema %d is newer than supported %d", e.Found, e.Supported) +} + +func Open(ctx context.Context, opts OpenOptions) (*Handle, error) { + if opts.Now == nil { + opts.Now = time.Now + } + if opts.MemoryName == "" { + opts.MemoryName = "projection" + } + mode := ModeDisk + status := Status{State: StateReady, Mode: mode, Path: opts.Path} + useMemory := opts.InMemory || PathLooksRemote(opts.Path) + if !useMemory { + if err := os.MkdirAll(filepath.Dir(opts.Path), 0o700); err != nil { + useMemory = true + status.LastError = err.Error() + } else { + _ = os.Chmod(filepath.Dir(opts.Path), 0o700) + if filesystemRemote(filepath.Dir(opts.Path)) { + useMemory = true + status.LastError = "projection cache is on a remote filesystem; using memory" + } + } + } + if useMemory { + mode = ModeMemory + } + + db, err := open(ctx, opts, mode) + if err != nil && mode == ModeDisk { + var future *FutureSchemaError + if errors.As(err, &future) { + status.State = StateDegraded + status.LastError = err.Error() + mode = ModeMemory + db, err = open(ctx, opts, mode) + } else { + status.QuarantinedPath = Quarantine(opts.Path, opts.Now()) + db, err = open(ctx, opts, mode) + } + } + if err != nil && mode == ModeDisk { + status.State = StateDegraded + status.LastError = err.Error() + mode = ModeMemory + db, err = open(ctx, opts, mode) + } + if err != nil { + return nil, err + } + status.Mode = mode + if mode == ModeMemory { + status.Path = "" + } + return &Handle{DB: db, Status: status}, nil +} + +func open(ctx context.Context, opts OpenOptions, mode Mode) (*sql.DB, error) { + var dsn string + if mode == ModeMemory { + dsn = fmt.Sprintf("file:reasonix-%s-%d?mode=memory&cache=shared", url.PathEscape(opts.MemoryName), opts.Now().UnixNano()) + } else { + u := &url.URL{Scheme: "file", Path: opts.Path} + dsn = u.String() + "?_pragma=busy_timeout%28150%29&_pragma=foreign_keys%281%29" + } + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + maxOpen := opts.MaxOpenConns + if maxOpen <= 0 { + maxOpen = 4 + } + db.SetMaxOpenConns(maxOpen) + db.SetMaxIdleConns(min(maxOpen, 2)) + fail := func(err error) (*sql.DB, error) { + _ = db.Close() + return nil, err + } + if err := db.PingContext(ctx); err != nil { + return fail(err) + } + if mode == ModeDisk { + if _, err := db.ExecContext(ctx, `PRAGMA journal_mode=WAL`); err != nil { + return fail(err) + } + } + for _, pragma := range []string{`PRAGMA synchronous=NORMAL`, `PRAGMA foreign_keys=ON`, `PRAGMA busy_timeout=150`} { + if _, err := db.ExecContext(ctx, pragma); err != nil { + return fail(err) + } + } + if opts.SecureDelete { + if _, err := db.ExecContext(ctx, `PRAGMA secure_delete=ON`); err != nil { + return fail(err) + } + } + if opts.AutoVacuum { + if _, err := db.ExecContext(ctx, `PRAGMA auto_vacuum=INCREMENTAL`); err != nil { + return fail(err) + } + } + var integrity string + if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil { + return fail(err) + } + if integrity != "ok" { + return fail(fmt.Errorf("projection integrity check: %s", integrity)) + } + if err := ApplyMigrations(ctx, db, opts.Migrations, opts.Now); err != nil { + return fail(err) + } + if mode == ModeDisk { + _ = os.Chmod(opts.Path, 0o600) + _ = os.Chmod(opts.Path+"-wal", 0o600) + _ = os.Chmod(opts.Path+"-shm", 0o600) + } + return db, nil +} + +func ApplyMigrations(ctx context.Context, db *sql.DB, migrations []Migration, now func() time.Time) error { + if now == nil { + now = time.Now + } + if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + )`); err != nil { + return err + } + var current int + if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(¤t); err != nil { + return err + } + supported := 0 + for _, migration := range migrations { + if migration.Version > supported { + supported = migration.Version + } + } + if current > supported { + return &FutureSchemaError{Found: current, Supported: supported} + } + for _, migration := range migrations { + if migration.Version <= current { + continue + } + if migration.Version != current+1 || migration.Apply == nil { + return fmt.Errorf("projection migration gap after version %d", current) + } + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := migration.Apply(ctx, tx); err != nil { + _ = tx.Rollback() + return fmt.Errorf("apply projection migration %d: %w", migration.Version, err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, migration.Version, now().UnixMilli()); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + current = migration.Version + } + return nil +} + +type Inspection struct { + Exists bool `json:"exists"` + Path string `json:"path"` + Schema int `json:"schema"` + Integrity string `json:"integrity,omitempty"` + Size int64 `json:"size"` + Error string `json:"error,omitempty"` +} + +// Inspect is deliberately read-only: it never creates, migrates, repairs, or +// quarantines a database. +func Inspect(ctx context.Context, path string) Inspection { + out := Inspection{Path: path} + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return out + } + if err != nil { + out.Error = err.Error() + return out + } + out.Exists = true + out.Size = info.Size() + u := &url.URL{Scheme: "file", Path: path} + db, err := sql.Open("sqlite", u.String()+"?mode=ro&immutable=1") + if err != nil { + out.Error = err.Error() + return out + } + defer db.Close() + if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&out.Integrity); err != nil { + out.Error = err.Error() + return out + } + if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&out.Schema); err != nil { + out.Error = err.Error() + } + return out +} + +func Quarantine(path string, now time.Time) string { + if strings.TrimSpace(path) == "" { + return "" + } + quarantined := fmt.Sprintf("%s.corrupt-%d", path, now.UnixMilli()) + if err := os.Rename(path, quarantined); err != nil { + return "" + } + for _, suffix := range []string{"-wal", "-shm"} { + _ = os.Rename(path+suffix, quarantined+suffix) + } + return quarantined +} + +func PathLooksRemote(path string) bool { + clean := filepath.Clean(path) + if runtime.GOOS == "windows" && strings.HasPrefix(clean, `\\`) { + return true + } + slash := filepath.ToSlash(clean) + return strings.HasPrefix(slash, "/net/") || strings.HasPrefix(slash, "/nfs/") || strings.HasPrefix(slash, "/afs/") +} + +// Rebuild constructs and validates a replacement beside the live database, +// then swaps it into place. The old projection remains untouched if building, +// validation, or the platform rename fails (notably an open database on +// Windows). Rebuild never touches authoritative business files. +func Rebuild(ctx context.Context, opts OpenOptions, populate func(context.Context, *sql.DB) error) error { + if strings.TrimSpace(opts.Path) == "" || opts.InMemory { + return errors.New("projection rebuild requires a disk path") + } + if opts.Now == nil { + opts.Now = time.Now + } + temporary := fmt.Sprintf("%s.rebuild-%d", opts.Path, opts.Now().UnixNano()) + replacement := opts + replacement.Path = temporary + replacement.InMemory = false + handle, err := Open(ctx, replacement) + if err != nil { + return fmt.Errorf("open projection replacement: %w", err) + } + cleanupTemporary := func() { + _ = os.Remove(temporary) + _ = os.Remove(temporary + "-wal") + _ = os.Remove(temporary + "-shm") + } + if handle.Status.Mode != ModeDisk { + _ = handle.DB.Close() + cleanupTemporary() + return errors.New("projection replacement could not use disk storage") + } + if populate != nil { + if err := populate(ctx, handle.DB); err != nil { + _ = handle.DB.Close() + cleanupTemporary() + return fmt.Errorf("populate projection replacement: %w", err) + } + } + var integrity string + if err := handle.DB.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil || integrity != "ok" { + _ = handle.DB.Close() + cleanupTemporary() + if err != nil { + return fmt.Errorf("validate projection replacement: %w", err) + } + return fmt.Errorf("validate projection replacement: %s", integrity) + } + _, _ = handle.DB.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`) + if err := handle.DB.Close(); err != nil { + cleanupTemporary() + return err + } + + backup := fmt.Sprintf("%s.replaced-%d", opts.Path, opts.Now().UnixNano()) + hadOld := false + if _, err := os.Stat(opts.Path); err == nil { + if err := os.Rename(opts.Path, backup); err != nil { + cleanupTemporary() + return fmt.Errorf("projection database is busy: %w", err) + } + hadOld = true + for _, suffix := range []string{"-wal", "-shm"} { + if err := os.Rename(opts.Path+suffix, backup+suffix); err != nil && !errors.Is(err, os.ErrNotExist) { + _ = os.Rename(backup, opts.Path) + for _, restored := range []string{"-wal", "-shm"} { + _ = os.Rename(backup+restored, opts.Path+restored) + } + cleanupTemporary() + return fmt.Errorf("projection database is busy: %w", err) + } + } + } else if !errors.Is(err, os.ErrNotExist) { + cleanupTemporary() + return err + } + if err := os.Rename(temporary, opts.Path); err != nil { + if hadOld { + _ = os.Rename(backup, opts.Path) + for _, suffix := range []string{"-wal", "-shm"} { + _ = os.Rename(backup+suffix, opts.Path+suffix) + } + } + cleanupTemporary() + return fmt.Errorf("install projection replacement: %w", err) + } + _ = os.Chmod(opts.Path, 0o600) + if hadOld { + _ = os.Remove(backup) + _ = os.Remove(backup + "-wal") + _ = os.Remove(backup + "-shm") + } + cleanupTemporary() + return nil +} diff --git a/internal/projectiondb/projectiondb_test.go b/internal/projectiondb/projectiondb_test.go new file mode 100644 index 0000000000..1f68d721a6 --- /dev/null +++ b/internal/projectiondb/projectiondb_test.go @@ -0,0 +1,134 @@ +package projectiondb + +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func testMigrations() []Migration { + return []Migration{{Version: 1, Apply: func(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `CREATE TABLE values_table(value TEXT NOT NULL)`) + return err + }}} +} + +func TestOpenAppliesLedgerAndPrivatePermissions(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "catalog", "v1.sqlite") + handle, err := Open(context.Background(), OpenOptions{Path: path, MemoryName: "test", Migrations: testMigrations()}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = handle.DB.Close() }) + var version int + if err := handle.DB.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version); err != nil || version != 1 { + t.Fatalf("version=%d err=%v", version, err) + } + if info, err := os.Stat(path); err != nil || info.Mode().Perm()&0o077 != 0 { + t.Fatalf("database permissions=%v err=%v", info.Mode().Perm(), err) + } +} + +func TestFutureSchemaIsPreservedAndFallsBackToMemory(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "catalog.sqlite") + seed, err := Open(context.Background(), OpenOptions{Path: path, MemoryName: "seed", Migrations: testMigrations()}) + if err != nil { + t.Fatal(err) + } + if _, err := seed.DB.Exec(`INSERT INTO schema_migrations(version, applied_at) VALUES(2, ?)`, time.Now().UnixMilli()); err != nil { + t.Fatal(err) + } + if err := seed.DB.Close(); err != nil { + t.Fatal(err) + } + handle, err := Open(context.Background(), OpenOptions{Path: path, MemoryName: "future", Migrations: testMigrations()}) + if err != nil { + t.Fatal(err) + } + defer handle.DB.Close() + if handle.Status.Mode != ModeMemory || handle.Status.State != StateDegraded || handle.Status.QuarantinedPath != "" { + t.Fatalf("status=%#v", handle.Status) + } + inspection := Inspect(context.Background(), path) + if !inspection.Exists || inspection.Schema != 2 { + t.Fatalf("inspection=%#v", inspection) + } +} + +func TestInspectDoesNotCreateMissingDatabase(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "missing.sqlite") + inspection := Inspect(context.Background(), path) + if inspection.Exists { + t.Fatalf("inspection=%#v", inspection) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("inspect created database: %v", err) + } +} + +func TestRebuildPublishesOnlyValidatedReplacement(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "catalog.sqlite") + opts := OpenOptions{Path: path, MemoryName: "rebuild", Migrations: testMigrations()} + seed, err := Open(context.Background(), opts) + if err != nil { + t.Fatal(err) + } + if _, err := seed.DB.Exec(`INSERT INTO values_table(value) VALUES('old')`); err != nil { + t.Fatal(err) + } + if err := seed.DB.Close(); err != nil { + t.Fatal(err) + } + if err := Rebuild(context.Background(), opts, func(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, `INSERT INTO values_table(value) VALUES('new')`) + return err + }); err != nil { + t.Fatal(err) + } + rebuilt, err := Open(context.Background(), opts) + if err != nil { + t.Fatal(err) + } + defer rebuilt.DB.Close() + var value string + if err := rebuilt.DB.QueryRow(`SELECT value FROM values_table`).Scan(&value); err != nil || value != "new" { + t.Fatalf("value=%q err=%v", value, err) + } +} + +func TestRebuildFailureKeepsOldDatabase(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "catalog.sqlite") + opts := OpenOptions{Path: path, MemoryName: "rebuild-failure", Migrations: testMigrations()} + seed, err := Open(context.Background(), opts) + if err != nil { + t.Fatal(err) + } + if _, err := seed.DB.Exec(`INSERT INTO values_table(value) VALUES('old')`); err != nil { + t.Fatal(err) + } + if err := seed.DB.Close(); err != nil { + t.Fatal(err) + } + wantErr := errors.New("populate failed") + if err := Rebuild(context.Background(), opts, func(context.Context, *sql.DB) error { return wantErr }); !errors.Is(err, wantErr) { + t.Fatalf("Rebuild error=%v", err) + } + current, err := Open(context.Background(), opts) + if err != nil { + t.Fatal(err) + } + defer current.DB.Close() + var value string + if err := current.DB.QueryRow(`SELECT value FROM values_table`).Scan(&value); err != nil || value != "old" { + t.Fatalf("value=%q err=%v", value, err) + } +} diff --git a/internal/projectiondb/remote_fs_darwin.go b/internal/projectiondb/remote_fs_darwin.go new file mode 100644 index 0000000000..5cbdf0fa9d --- /dev/null +++ b/internal/projectiondb/remote_fs_darwin.go @@ -0,0 +1,28 @@ +//go:build darwin + +package projectiondb + +import ( + "strings" + "syscall" +) + +func filesystemRemote(path string) bool { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return false + } + name := make([]byte, 0, len(stat.Fstypename)) + for _, char := range stat.Fstypename { + if char == 0 { + break + } + name = append(name, byte(char)) + } + switch strings.ToLower(string(name)) { + case "nfs", "smbfs", "webdav", "afpfs", "cifs": + return true + default: + return false + } +} diff --git a/internal/projectiondb/remote_fs_linux.go b/internal/projectiondb/remote_fs_linux.go new file mode 100644 index 0000000000..a814e8e048 --- /dev/null +++ b/internal/projectiondb/remote_fs_linux.go @@ -0,0 +1,18 @@ +//go:build linux + +package projectiondb + +import "syscall" + +func filesystemRemote(path string) bool { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return false + } + switch uint64(stat.Type) { + case 0x6969, 0x517B, 0xFF534D42, 0x6E667364, 0x00C36400, 0x0BD00BD0: + return true + default: + return false + } +} diff --git a/internal/projectiondb/remote_fs_other.go b/internal/projectiondb/remote_fs_other.go new file mode 100644 index 0000000000..831f7f4103 --- /dev/null +++ b/internal/projectiondb/remote_fs_other.go @@ -0,0 +1,5 @@ +//go:build !darwin && !linux && !windows + +package projectiondb + +func filesystemRemote(string) bool { return false } diff --git a/internal/projectiondb/remote_fs_windows.go b/internal/projectiondb/remote_fs_windows.go new file mode 100644 index 0000000000..9be1077214 --- /dev/null +++ b/internal/projectiondb/remote_fs_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package projectiondb + +import ( + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +func filesystemRemote(path string) bool { + volume := filepath.VolumeName(filepath.Clean(path)) + if volume == "" { + return false + } + root := volume + if !strings.HasSuffix(root, `\`) { + root += `\` + } + rootPtr, err := windows.UTF16PtrFromString(root) + return err == nil && windows.GetDriveType(rootPtr) == windows.DRIVE_REMOTE +} diff --git a/internal/sessioncatalog/catalog.go b/internal/sessioncatalog/catalog.go index bd79714328..450630c6f6 100644 --- a/internal/sessioncatalog/catalog.go +++ b/internal/sessioncatalog/catalog.go @@ -7,16 +7,14 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "os" "path/filepath" - "runtime" "strings" "sync" "sync/atomic" "time" - _ "modernc.org/sqlite" + "reasonix/internal/projectiondb" ) const defaultMissingGrace = 30 * time.Second @@ -84,38 +82,25 @@ func Open(ctx context.Context, opts Options) (*Catalog, error) { stop: make(chan struct{}), status: Status{State: StateOpening, Path: opts.Path}, } - mode := ModeDisk - useMemory := opts.InMemory || catalogPathLooksRemote(opts.Path) - if !useMemory { - if err := os.MkdirAll(filepath.Dir(opts.Path), 0o755); err != nil { - useMemory = true - c.status.LastError = err.Error() - } else if catalogFilesystemRemote(filepath.Dir(opts.Path)) { - useMemory = true - c.status.LastError = "session catalog cache is on a remote filesystem; using memory" - } - } - if useMemory { - mode = ModeMemory - } - db, err := openDatabase(ctx, opts.Path, mode) - if err != nil && mode == ModeDisk { - quarantined := quarantineCatalog(opts.Path, opts.Now()) - c.status.QuarantinedPath = quarantined - db, err = openDatabase(ctx, opts.Path, mode) - } - if err != nil && mode == ModeDisk { - mode = ModeMemory - c.status.LastError = err.Error() - db, err = openDatabase(ctx, opts.Path, mode) - } + handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{ + Path: opts.Path, MemoryName: "session-catalog", Migrations: schemaMigrations(), + InMemory: opts.InMemory || os.Getenv("REASONIX_SESSION_CATALOG_MEMORY") == "1", + MaxOpenConns: 4, Now: opts.Now, + }) if err != nil { return nil, err } + db := handle.DB c.db = db - c.status.Mode = mode - c.status.State = StateReady - if mode == ModeMemory { + c.status.Mode = Mode(handle.Status.Mode) + c.status.LastError = handle.Status.LastError + c.status.QuarantinedPath = handle.Status.QuarantinedPath + if handle.Status.State == projectiondb.StateDegraded { + c.status.State = StateDegraded + } else { + c.status.State = StateReady + } + if handle.Status.Mode == projectiondb.ModeMemory { c.status.Path = "" } if err := c.loadStatus(ctx); err != nil { @@ -137,75 +122,6 @@ func Open(ctx context.Context, opts Options) (*Catalog, error) { return c, nil } -func openDatabase(ctx context.Context, path string, mode Mode) (*sql.DB, error) { - dsn := "" - if mode == ModeMemory { - dsn = fmt.Sprintf("file:reasonix-session-catalog-%d?mode=memory&cache=shared", time.Now().UnixNano()) - } else { - u := &url.URL{Scheme: "file", Path: path} - dsn = u.String() + "?_pragma=busy_timeout%28150%29&_pragma=foreign_keys%281%29" - } - db, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(4) - db.SetMaxIdleConns(2) - if err := db.PingContext(ctx); err != nil { - _ = db.Close() - return nil, err - } - if mode == ModeDisk { - if _, err := db.ExecContext(ctx, `PRAGMA journal_mode=WAL`); err != nil { - _ = db.Close() - return nil, err - } - } - if _, err := db.ExecContext(ctx, `PRAGMA synchronous=NORMAL`); err != nil { - _ = db.Close() - return nil, err - } - var integrity string - if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil || integrity != "ok" { - _ = db.Close() - if err != nil { - return nil, err - } - return nil, fmt.Errorf("session catalog integrity check: %s", integrity) - } - if err := migrateSchema(ctx, db); err != nil { - _ = db.Close() - return nil, err - } - return db, nil -} - -func quarantineCatalog(path string, now time.Time) string { - if strings.TrimSpace(path) == "" { - return "" - } - quarantined := fmt.Sprintf("%s.corrupt-%d", path, now.UnixMilli()) - if err := os.Rename(path, quarantined); err != nil { - return "" - } - for _, suffix := range []string{"-wal", "-shm"} { - _ = os.Rename(path+suffix, quarantined+suffix) - } - return quarantined -} - -func catalogPathLooksRemote(path string) bool { - if os.Getenv("REASONIX_SESSION_CATALOG_MEMORY") == "1" { - return true - } - clean := filepath.Clean(path) - if runtime.GOOS == "windows" && strings.HasPrefix(clean, `\\`) { - return true - } - slash := filepath.ToSlash(clean) - return strings.HasPrefix(slash, "/net/") || strings.HasPrefix(slash, "/nfs/") || strings.HasPrefix(slash, "/afs/") -} - func (c *Catalog) loadStatus(ctx context.Context) error { var revision uint64 if err := c.db.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&revision); err != nil { diff --git a/internal/sessioncatalog/catalog_test.go b/internal/sessioncatalog/catalog_test.go index 6ea7a0ace1..dd9dc27964 100644 --- a/internal/sessioncatalog/catalog_test.go +++ b/internal/sessioncatalog/catalog_test.go @@ -220,11 +220,45 @@ func TestSchemaMigrationLedgerRecordsEveryVersion(t *testing.T) { } versions = append(versions, version) } - if fmt.Sprint(versions) != "[1 2]" { + if fmt.Sprint(versions) != "[1 2 3]" { t.Fatalf("schema migration ledger = %v", versions) } } +func TestListSessionsUsesRevisionBoundKeysetCursor(t *testing.T) { + t.Parallel() + ctx := context.Background() + catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + for i, name := range []string{"a", "b", "c"} { + if err := catalog.UpsertSession(ctx, SessionRecord{ + Path: filepath.Join("/sessions", name+".jsonl"), Directory: "/sessions", Scope: "global", + TopicID: name, CustomTitle: "title " + name, LastActivityAt: int64(i + 1), + TurnsState: TurnsValid, Health: HealthOK, + }); err != nil { + t.Fatal(err) + } + } + first, err := catalog.ListSessions(ctx, SessionPageRequest{Scope: "all", Limit: 2}) + if err != nil || len(first.Items) != 2 || first.NextCursor == "" { + t.Fatalf("first=%#v err=%v", first, err) + } + second, err := catalog.ListSessions(ctx, SessionPageRequest{Scope: "all", Limit: 2, Cursor: first.NextCursor}) + if err != nil || len(second.Items) != 1 || second.Items[0].CustomTitle != "title a" { + t.Fatalf("second=%#v err=%v", second, err) + } + if err := catalog.UpsertSession(ctx, SessionRecord{Path: "/sessions/d.jsonl", Directory: "/sessions", Scope: "global", TopicID: "d", LastActivityAt: 4, TurnsState: TurnsValid, Health: HealthOK}); err != nil { + t.Fatal(err) + } + stale, err := catalog.ListSessions(ctx, SessionPageRequest{Scope: "all", Cursor: first.NextCursor}) + if err != nil || !stale.StaleCursor || len(stale.Items) != 0 { + t.Fatalf("stale=%#v err=%v", stale, err) + } +} + func TestDirectWriteDuringScanIsNotMarkedMissing(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/sessioncatalog/schema.go b/internal/sessioncatalog/schema.go index 0eb3f777ff..27b4351782 100644 --- a/internal/sessioncatalog/schema.go +++ b/internal/sessioncatalog/schema.go @@ -3,8 +3,8 @@ package sessioncatalog import ( "context" "database/sql" - "fmt" - "time" + + "reasonix/internal/projectiondb" ) const migrationV1 = ` @@ -96,54 +96,26 @@ const migrationV2 = ` ALTER TABLE catalog_topics ADD COLUMN metadata_present INTEGER NOT NULL DEFAULT 0; ` -func migrateSchema(ctx context.Context, db *sql.DB) error { - if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at INTEGER NOT NULL - )`); err != nil { +const migrationV3 = ` +CREATE INDEX IF NOT EXISTS idx_catalog_sessions_history +ON catalog_sessions(scope, workspace_root, last_activity_at DESC, path ASC); +` + +func execMigration(statement string) func(context.Context, *sql.Tx) error { + return func(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, statement) return err } - var version int - if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&version); err != nil { - return err - } - if version > SchemaVersion { - return fmt.Errorf("session catalog schema %d is newer than supported %d", version, SchemaVersion) - } - if version < 1 { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, migrationV1); err != nil { - _ = tx.Rollback() - return err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(1, ?)`, time.Now().UnixMilli()); err != nil { - _ = tx.Rollback() - return err - } - if err := tx.Commit(); err != nil { - return err - } - version = 1 - } - if version < 2 { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, migrationV2); err != nil { - _ = tx.Rollback() - return err - } - if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(2, ?)`, time.Now().UnixMilli()); err != nil { - _ = tx.Rollback() - return err - } - if err := tx.Commit(); err != nil { - return err - } - } - return nil +} + +func schemaMigrations() []projectiondb.Migration { + return []projectiondb.Migration{ + {Version: 1, Apply: execMigration(migrationV1)}, + {Version: 2, Apply: execMigration(migrationV2)}, + {Version: 3, Apply: execMigration(migrationV3)}, + } +} + +func migrateSchema(ctx context.Context, db *sql.DB) error { + return projectiondb.ApplyMigrations(ctx, db, schemaMigrations(), nil) } diff --git a/internal/sessioncatalog/session_page.go b/internal/sessioncatalog/session_page.go new file mode 100644 index 0000000000..07252a5ab3 --- /dev/null +++ b/internal/sessioncatalog/session_page.go @@ -0,0 +1,158 @@ +package sessioncatalog + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + "time" +) + +type sessionPageCursor struct { + Revision uint64 `json:"r"` + Activity int64 `json:"a"` + Path string `json:"p"` +} + +const sessionSelectColumns = `path,directory,scope,workspace_root,topic_id,topic_title, + custom_title,created_at,last_activity_at,preview,turns,turns_state,recovered, + recovery_reason,recovery_digest,parent_id,content_fingerprint,meta_fingerprint, + health,missing_since` + +func scanSession(scanner interface{ Scan(...any) error }) (SessionRecord, error) { + var record SessionRecord + err := scanner.Scan(&record.Path, &record.Directory, &record.Scope, &record.WorkspaceRoot, + &record.TopicID, &record.TopicTitle, &record.CustomTitle, &record.CreatedAt, + &record.LastActivityAt, &record.Preview, &record.Turns, &record.TurnsState, + &record.Recovered, &record.RecoveryReason, &record.RecoveryDigest, + &record.ParentID, &record.ContentFingerprint, &record.MetaFingerprint, + &record.Health, &record.MissingSince) + return record, err +} + +// ListSessions returns only catalog metadata. It never opens a transcript or +// sidecar and therefore remains safe on startup and UI pagination paths. +func (c *Catalog) ListSessions(ctx context.Context, req SessionPageRequest) (SessionPage, error) { + out := SessionPage{Items: []SessionRecord{}, Revision: c.revision.Load()} + if req.Limit <= 0 { + req.Limit = DefaultLimit + } + if req.Limit > MaxLimit { + req.Limit = MaxLimit + } + cursor, err := decodeSessionCursor(req.Cursor) + if err != nil { + return out, err + } + if cursor != nil && cursor.Revision != out.Revision { + out.StaleCursor = true + return out, nil + } + where := []string{`missing_since=0`, `health<>'missing'`} + args := []any{} + switch strings.ToLower(strings.TrimSpace(req.Scope)) { + case "", "all": + case "project": + where = append(where, `scope='project'`, `workspace_root=?`) + args = append(args, strings.TrimSpace(req.WorkspaceRoot)) + case "global": + where = append(where, `scope='global'`) + default: + return out, fmt.Errorf("invalid session catalog scope %q", req.Scope) + } + if directory := strings.TrimSpace(req.Directory); directory != "" { + where = append(where, `directory=?`) + args = append(args, filepath.Clean(directory)) + } + if query := strings.ToLower(strings.TrimSpace(req.Query)); query != "" { + where = append(where, `(lower(custom_title) LIKE ? OR lower(preview) LIKE ? OR lower(topic_title) LIKE ? OR lower(topic_id) LIKE ?)`) + like := "%" + query + "%" + args = append(args, like, like, like, like) + } + appendSessionTimeFilter(&where, &args, req.TimeFilter, c.opts.Now()) + if cursor != nil { + where = append(where, `(last_activity_at?))`) + args = append(args, cursor.Activity, cursor.Activity, cursor.Path) + } + args = append(args, req.Limit+1) + rows, err := c.db.QueryContext(ctx, `SELECT `+sessionSelectColumns+` FROM catalog_sessions WHERE `+ + strings.Join(where, ` AND `)+` ORDER BY last_activity_at DESC,path ASC LIMIT ?`, args...) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + record, err := scanSession(rows) + if err != nil { + return out, err + } + out.Items = append(out.Items, record) + } + if err := rows.Err(); err != nil { + return out, err + } + if len(out.Items) > req.Limit { + out.Items = out.Items[:req.Limit] + last := out.Items[len(out.Items)-1] + out.NextCursor = encodeSessionCursor(sessionPageCursor{Revision: out.Revision, Activity: last.LastActivityAt, Path: last.Path}) + } + return out, nil +} + +func appendSessionTimeFilter(where *[]string, args *[]any, filter string, now time.Time) { + value := strings.ToLower(strings.TrimSpace(filter)) + startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + switch value { + case "", "all": + case "today": + *where = append(*where, `last_activity_at>=?`) + *args = append(*args, startToday.UnixMilli()) + case "yesterday": + *where = append(*where, `last_activity_at>=?`, `last_activity_at 0 { + *where = append(*where, `last_activity_at>=?`) + *args = append(*args, cutoff) + } + } +} + +func (c *Catalog) GetSession(ctx context.Context, path string) (SessionRecord, bool, error) { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "." || path == "" { + return SessionRecord{}, false, nil + } + record, err := scanSession(c.db.QueryRowContext(ctx, `SELECT `+sessionSelectColumns+` FROM catalog_sessions WHERE path=?`, path)) + if errors.Is(err, sql.ErrNoRows) { + return SessionRecord{}, false, nil + } + return record, err == nil, err +} + +func encodeSessionCursor(cursor sessionPageCursor) string { + b, _ := json.Marshal(cursor) + return base64.RawURLEncoding.EncodeToString(b) +} + +func decodeSessionCursor(encoded string) (*sessionPageCursor, error) { + if strings.TrimSpace(encoded) == "" { + return nil, nil + } + b, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("invalid session catalog cursor: %w", err) + } + var cursor sessionPageCursor + if err := json.Unmarshal(b, &cursor); err != nil || cursor.Path == "" { + return nil, errors.New("invalid session catalog cursor") + } + return &cursor, nil +} diff --git a/internal/sessioncatalog/types.go b/internal/sessioncatalog/types.go index e34acda2bd..62833a1349 100644 --- a/internal/sessioncatalog/types.go +++ b/internal/sessioncatalog/types.go @@ -11,7 +11,7 @@ import ( ) const ( - SchemaVersion = 2 + SchemaVersion = 3 DefaultLimit = 50 MaxLimit = 200 ) @@ -158,6 +158,23 @@ type TopicPage struct { Revision uint64 `json:"revision"` } +type SessionPageRequest struct { + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + Directory string `json:"-"` + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` + Query string `json:"query,omitempty"` + TimeFilter string `json:"timeFilter,omitempty"` +} + +type SessionPage struct { + Items []SessionRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` + Revision uint64 `json:"revision"` + StaleCursor bool `json:"staleCursor,omitempty"` +} + func DefaultPath() string { return filepath.Join(config.CacheDir(), "session-catalog", "v1.sqlite") } From 7d99299c98012e32259ccc40e76fef299fcbcd7c Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:39:20 +0800 Subject: [PATCH 2/6] feat(tasks): add cross-project task catalog Problem: Task Monitor could only scan the active project synchronously and could not page or safely route controls across projects. Root cause: Task snapshots and event logs had no disposable query projection, while control APIs implicitly depended on the active tab instead of a stable project identity. Fix: Add an observed authoritative FileStore, resumable snapshot and lazy event indexing, SHA-256 project-key routing, runtime overlays, paged Wails APIs, a cross-project Task Center, diagnostics, and bilingual documentation. Verification: Covered by lock-boundary notifications, corrupt snapshot/event isolation, event-tail pagination, lease reconciliation, stale cursor, project-key action routing, Wails arrays, and 100,000-task warm-page benchmark tests. --- desktop/app.go | 11 +- desktop/frontend/src/App.tsx | 14 +- .../src/components/TaskMonitorPanel.tsx | 132 +++- desktop/frontend/src/lib/bridge.ts | 4 +- desktop/frontend/src/lib/taskCatalogBridge.ts | 27 + desktop/frontend/src/lib/taskCatalogTypes.ts | 14 + desktop/frontend/src/lib/types.ts | 2 + desktop/frontend/src/locales/en.ts | 1 + desktop/frontend/src/locales/zh-TW.ts | 1 + desktop/frontend/src/locales/zh.ts | 1 + desktop/session_catalog.go | 13 + desktop/settings_app.go | 1 + desktop/tabs.go | 1 + desktop/task_catalog.go | 281 ++++++++ docs/TASK_CATALOG.md | 26 + docs/TASK_CATALOG.zh-CN.md | 21 + internal/boot/boot.go | 3 + internal/cli/catalogs_tasks.go | 50 ++ internal/control/controller.go | 10 +- internal/taskcatalog/benchmark_test.go | 50 ++ internal/taskcatalog/catalog.go | 666 ++++++++++++++++++ internal/taskcatalog/catalog_test.go | 97 +++ internal/taskcatalog/shared.go | 103 +++ internal/taskmonitor/event_tail.go | 75 ++ internal/taskmonitor/event_tail_test.go | 49 ++ internal/taskmonitor/jsonstore.go | 19 + internal/taskmonitor/model.go | 4 + internal/taskmonitor/projection_sink_test.go | 56 ++ internal/taskmonitor/store.go | 7 + 29 files changed, 1690 insertions(+), 49 deletions(-) create mode 100644 desktop/frontend/src/lib/taskCatalogBridge.ts create mode 100644 desktop/frontend/src/lib/taskCatalogTypes.ts create mode 100644 desktop/task_catalog.go create mode 100644 docs/TASK_CATALOG.md create mode 100644 docs/TASK_CATALOG.zh-CN.md create mode 100644 internal/cli/catalogs_tasks.go create mode 100644 internal/taskcatalog/benchmark_test.go create mode 100644 internal/taskcatalog/catalog.go create mode 100644 internal/taskcatalog/catalog_test.go create mode 100644 internal/taskcatalog/shared.go create mode 100644 internal/taskmonitor/event_tail.go create mode 100644 internal/taskmonitor/event_tail_test.go create mode 100644 internal/taskmonitor/projection_sink_test.go diff --git a/desktop/app.go b/desktop/app.go index c38849a3f4..ecdb7e8fa2 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -58,6 +58,7 @@ import ( "reasonix/internal/skill" "reasonix/internal/stats" "reasonix/internal/store" + "reasonix/internal/taskcatalog" "reasonix/internal/taskmonitor" "reasonix/internal/tool" ) @@ -991,9 +992,10 @@ func (a *App) shutdown(context.Context) { // Run after controller teardown (and after its deferred lifecycle unlocks) // so every accepted usage record reaches disk before a normal app exit. defer func() { - flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + flushCtx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) defer cancel() _ = stats.Flush(flushCtx, config.StatsDir()) + _ = flushDesktopTaskCatalog(flushCtx) }() a.stopDeferredRebuildRetry() a.stopHistoryIndexMigration() @@ -2401,6 +2403,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi RequireKey: false, AutoPricingCurrency: a.desktopAutoPricingCurrency(), StatsSource: "desktop", + TaskStore: a.taskStore(), Sink: newSink, WorkspaceRoot: snap.workspaceRoot, SessionDir: sessionDirForSnapshot(snap), @@ -4502,6 +4505,7 @@ func (a *App) buildSessionRebindCandidate( RequireKey: false, AutoPricingCurrency: a.desktopAutoPricingCurrency(), StatsSource: "desktop", + TaskStore: a.taskStore(), Sink: a.desktopControllerSink(sink, cfg.Notifications), WorkspaceRoot: root, SessionDir: sessionDir, @@ -10072,6 +10076,7 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) { RequireKey: false, AutoPricingCurrency: a.desktopAutoPricingCurrency(), StatsSource: "desktop", + TaskStore: a.taskStore(), Sink: snap.sink, WorkspaceRoot: snap.workspaceRoot, SessionDir: sessionDirForSnapshot(snap), @@ -10255,6 +10260,7 @@ func (a *App) SetEffortForTab(tabID, level string) error { RequireKey: false, AutoPricingCurrency: a.desktopAutoPricingCurrency(), StatsSource: "desktop", + TaskStore: a.taskStore(), Sink: snap.sink, WorkspaceRoot: snap.workspaceRoot, SessionDir: sessionDirForSnapshot(snap), @@ -10395,6 +10401,7 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error { RequireKey: false, AutoPricingCurrency: a.desktopAutoPricingCurrency(), StatsSource: "desktop", + TaskStore: a.taskStore(), Sink: snap.sink, WorkspaceRoot: snap.workspaceRoot, SessionDir: sessionDirForSnapshot(snap), @@ -11916,7 +11923,7 @@ func parseScope(s string) memory.Scope { // taskStore is the Store backing the task monitor panel. func (a *App) taskStore() taskmonitor.WriteStore { - return taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks")) + return taskcatalog.ObservedStore() } // taskControl returns the process-wide ControlService backing the task diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index be73f51505..e67d242219 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -1188,7 +1188,7 @@ export default function App() { const sidebarWidth = useLayoutStore((s) => s.sidebarWidth); const setSidebarWidth = useLayoutStore((s) => s.setSidebarWidth); const [sidebarResizing, setSidebarResizing] = useState(false); - const [tasksOpen, setTasksOpen] = useState(false); + const [tasksOpen, setTasksOpen] = useState(false); const [liveSidebarWidth, setLiveSidebarWidth] = useState(null); const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1440 : window.innerWidth)); const [viewportHeight, setViewportHeight] = useState(() => (typeof window === "undefined" ? 720 : window.innerHeight)); @@ -3988,6 +3988,7 @@ export default function App() { setSettingsTarget("models"); }, }, + { id: "cmd-task-center", group: t("palette.group.commands"), title: t("palette.cmd.taskCenter"), icon: , compact: true, keywords: ["task", "tasks", "center", "任务", "任务中心"], run: () => setTasksOpen("all") }, { id: "cmd-terminal", group: t("palette.group.commands"), title: t("rightDock.terminal"), icon: , compact: true, keywords: ["terminal", "shell", "终端"], run: () => toggleTerminalPanel() }, { id: "cmd-reload-runtime", @@ -3999,8 +4000,7 @@ export default function App() { run: () => { const tabID = activeTab?.id; if (!tabID) return; - // Success/queued feedback arrives as a tab notice from the Go side; - // only hard failures need a toast here. + // Success/queued feedback arrives as a tab notice; only hard failures need a toast. void app.ReloadRuntime(tabID).catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); }, }, @@ -4801,8 +4801,8 @@ export default function App() { className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`} type="button" aria-label="Session summary" - aria-expanded={tasksOpen} - onClick={() => setTasksOpen((open) => !open)} + aria-expanded={Boolean(tasksOpen)} + onClick={() => setTasksOpen((open) => open ? false : "session")} > @@ -4811,9 +4811,9 @@ export default function App() {
setTasksOpen(false)} diff --git a/desktop/frontend/src/components/TaskMonitorPanel.tsx b/desktop/frontend/src/components/TaskMonitorPanel.tsx index 9426243a3a..561b11b9ef 100644 --- a/desktop/frontend/src/components/TaskMonitorPanel.tsx +++ b/desktop/frontend/src/components/TaskMonitorPanel.tsx @@ -14,6 +14,13 @@ import { app } from "../lib/bridge"; import { useT } from "../lib/i18n"; import type { TaskEvent, TaskSnapshot } from "../lib/types"; +type CatalogTask = TaskSnapshot & { __projectKey: string; __projectLabel: string; __catalogKey: string }; + +function hasTaskCatalogBinding(): boolean { + const bound = (window as unknown as { go?: { main?: { App?: { ListTaskPage?: unknown } } } }).go?.main?.App?.ListTaskPage; + return typeof bound === "function"; +} + // --- helpers --- type TaskTimerSnapshot = TaskSnapshot & { runtime_lease_until?: string }; @@ -107,6 +114,7 @@ export function TaskMonitorPanel({ onClose, onOpenSession, initialOpen = false, + initialScope = "session", popover = false, summaryMode = false, }: { @@ -114,11 +122,17 @@ export function TaskMonitorPanel({ onClose?: () => void; onOpenSession?: (tabID: string, taskID: string) => Promise | boolean; initialOpen?: boolean; + initialScope?: "session" | "project" | "all"; popover?: boolean; summaryMode?: boolean; }) { const t = useT(); - const [tasks, setTasks] = useState([]); + const [tasks, setTasks] = useState([]); + const [scope, setScope] = useState<"session" | "project" | "all">(initialScope); + const [query, setQuery] = useState(""); + const [nextCursor, setNextCursor] = useState(""); + const [indexProgress, setIndexProgress] = useState<{ indexed: number; total: number; partial: boolean }>({ indexed: 0, total: 0, partial: true }); + const requestSeq = useRef(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>(new Set()); @@ -127,7 +141,7 @@ export function TaskMonitorPanel({ const [actionError, setActionError] = useState(null); const [actionMessage, setActionMessage] = useState(null); const [nowMs, setNowMs] = useState(() => Date.now()); - const [pendingAction, setPendingAction] = useState<{ task: TaskSnapshot; action: "stop" | "cancel" } | null>(null); + const [pendingAction, setPendingAction] = useState<{ task: CatalogTask; action: "stop" | "cancel" } | null>(null); // Per-task event state const [taskEvents, setTaskEvents] = useState>( @@ -139,20 +153,36 @@ export function TaskMonitorPanel({ ); const eventCursors = useRef>(new Map()); - const fetchTasks = useCallback(async () => { + const fetchTasks = useCallback(async (cursor = "") => { + const seq = ++requestSeq.current; try { setError(null); - const list = await app.ListTasksForTab(tabID); - setTasks(list ?? []); + if (!hasTaskCatalogBinding()) { + const legacy = await app.ListTasksForTab(tabID); + if (seq !== requestSeq.current) return; + const filtered = legacy.filter((task) => !query.trim() || [task.task_id, task.session_id, task.error_code, task.error_summary].some((value) => (value || "").toLowerCase().includes(query.trim().toLowerCase()))); + setTasks(filtered.map((task) => ({ ...task, __projectKey: "", __projectLabel: "", __catalogKey: task.task_id }))); + setNextCursor(""); + setIndexProgress({ indexed: filtered.length, total: filtered.length, partial: false }); + return; + } + const page = await app.ListTaskPage({ scope, tabId: tabID, projectKey: "", states: [], query, cursor, limit: 50 }); + if (seq !== requestSeq.current) return; + const decorated = (page.items ?? []).map((item) => ({ ...item.task, __projectKey: item.projectKey, __projectLabel: item.projectLabel, __catalogKey: `${item.projectKey}:${item.task.task_id}` })); + setTasks((current) => cursor ? [...current, ...decorated.filter((item) => !current.some((existing) => existing.__catalogKey === item.__catalogKey))] : decorated); + setNextCursor(page.nextCursor || ""); + setIndexProgress({ indexed: page.status.indexed, total: page.status.total, partial: page.partial }); } catch (e) { + if (seq !== requestSeq.current) return; setError(String(e)); } finally { setLoading(false); } - }, [tabID]); + }, [query, scope, tabID]); // Fetch events for a single task, using afterSequence for incremental load. - const fetchEvents = useCallback(async (taskID: string) => { + const fetchEvents = useCallback(async (task: CatalogTask) => { + const taskID = task.__catalogKey; setEventsLoading((prev) => new Set(prev).add(taskID)); setEventsError((prev) => { const next = new Map(prev); @@ -161,7 +191,9 @@ export function TaskMonitorPanel({ }); try { const cursor = eventCursors.current.get(taskID) ?? 0; - const events = await app.ListTaskEventsForTab(tabID, taskID, cursor); + const events = hasTaskCatalogBinding() + ? (await app.ListTaskEventPage({ projectKey: task.__projectKey, taskId: task.task_id, after: cursor, limit: 50 })).items ?? [] + : await app.ListTaskEventsForTab(tabID, task.task_id, cursor); if (events.length > 0) { setTaskEvents((prev) => { const next = new Map(prev); @@ -193,20 +225,16 @@ export function TaskMonitorPanel({ return next; }); } - }, [tabID]); + }, [tabID]); // Initial fetch + periodic polling useEffect(() => { - fetchTasks(); + void fetchTasks(""); const interval = setInterval(() => { - fetchTasks(); - // Also refresh events for expanded tasks - expanded.forEach((id) => { - fetchEvents(id); - }); + void fetchTasks(""); }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [fetchTasks, fetchEvents, expanded]); + }, [fetchTasks]); // Live tasks need a ticking clock; terminal and queued tasks stay frozen at // their persisted end/update time. @@ -216,7 +244,8 @@ export function TaskMonitorPanel({ return () => clearInterval(interval); }, [tasks]); - const toggleTask = (id: string) => { + const toggleTask = (task: CatalogTask) => { + const id = task.__catalogKey; setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) { @@ -225,14 +254,14 @@ export function TaskMonitorPanel({ next.add(id); // Load events on first expand if (!taskEvents.has(id)) { - fetchEvents(id); + void fetchEvents(task); } } return next; }); }; - const controlTask = async (task: TaskSnapshot, action: "stop" | "cancel" | "requeue" | "open") => { + const controlTask = async (task: CatalogTask, action: "stop" | "cancel" | "requeue" | "open") => { if ((action === "stop" || action === "cancel") && (!pendingAction || pendingAction.task.task_id !== task.task_id || pendingAction.action !== action)) { setPendingAction({ task, action }); return; @@ -242,18 +271,27 @@ export function TaskMonitorPanel({ setActionError(null); setActionMessage(null); try { - if (action === "open" && onOpenSession) { + if (action === "open" && onOpenSession && scope === "session") { const opened = await onOpenSession(tabID, task.task_id); if (opened) onClose?.(); return; } - const result = action === "stop" - ? await app.StopTaskForTab(tabID, task.task_id, task.version, "desktop request", `desktop-${action}-${task.task_id}-${task.version}`) - : action === "cancel" - ? await app.CancelTaskForTab(tabID, task.task_id, task.version, "desktop request", `desktop-${action}-${task.task_id}-${task.version}`) - : action === "requeue" - ? await app.RequeueTaskForTab(tabID, task.task_id, task.version, `desktop-${action}-${task.task_id}-${task.version}`) - : await app.OpenTaskSessionForTab(tabID, task.task_id); + const request = { projectKey: task.__projectKey, taskId: task.task_id, expectedVersion: task.version, reason: "desktop request", idempotencyKey: `desktop-${action}-${task.task_id}-${task.version}` }; + const result = hasTaskCatalogBinding() + ? action === "stop" + ? await app.StopTaskByKey(request) + : action === "cancel" + ? await app.CancelTaskByKey(request) + : action === "requeue" + ? await app.RequeueTaskByKey(request) + : await app.OpenTaskSessionByKey({ projectKey: task.__projectKey, taskId: task.task_id }) + : action === "stop" + ? await app.StopTaskForTab(tabID, task.task_id, task.version, request.reason, request.idempotencyKey) + : action === "cancel" + ? await app.CancelTaskForTab(tabID, task.task_id, task.version, request.reason, request.idempotencyKey) + : action === "requeue" + ? await app.RequeueTaskForTab(tabID, task.task_id, task.version, request.idempotencyKey) + : await app.OpenTaskSessionForTab(tabID, task.task_id); if (result.error) { setActionError(`${result.error.code}: ${result.error.message}`); } else if (action === "open") { @@ -262,7 +300,7 @@ export function TaskMonitorPanel({ setActionMessage(`Session: ${sessionID}`); } else { setActionMessage(result.idempotent ? "Already applied" : "Task updated"); - await fetchTasks(); + await fetchTasks(""); } } catch (e) { setActionError(String(e)); @@ -291,9 +329,9 @@ export function TaskMonitorPanel({ {tasks.length}
+ )} + {evLoading && evs.length === 0 && (
@@ -509,6 +560,11 @@ export function TaskMonitorPanel({
); })} + {nextCursor && !loading && !error && ( + + )} )} diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index b8b94e7aa5..2d66bc114c 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -8,6 +8,7 @@ import type { InvocationRequest } from "./invocationDisplay"; import { addBreadcrumb } from "./breadcrumbs"; import { maybeShare } from "./queryCoalesce"; import { makeMockSessionCatalogBindings } from "./sessionCatalogBridge"; +import { makeMockTaskCatalogBindings, type TaskCatalogBindings } from "./taskCatalogBridge"; import { t } from "./i18n"; import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels"; import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems"; @@ -162,7 +163,7 @@ interface DesktopWindowState { // added or renamed, the generated types shift, and a key present in GeneratedApp // but missing from AppBindings causes a type error here. Fix: add the new method // to AppBindings, then run `pnpm typecheck` to verify. -export interface AppBindings extends SessionCatalogBindings { +export interface AppBindings extends SessionCatalogBindings, TaskCatalogBindings { Platform(): Promise; MinimiseMainWindow(): Promise; ToggleMaximiseMainWindow(): Promise; @@ -4907,6 +4908,7 @@ function makeMockApp(): AppBindings { async RequeueTask() { return { schema_version: 1, command: "requeue", task_id: "", accepted: false, idempotent: false, error: { code: "mock", message: "not available in browser mock" } }; }, async OpenTaskSession() { return { schema_version: 1, command: "open_session", task_id: "", accepted: false, idempotent: false, error: { code: "mock", message: "not available in browser mock" } }; }, async ListTasksForTab() { return []; }, + ...makeMockTaskCatalogBindings(), async ListTaskEventsForTab() { return []; }, async StopTaskForTab() { return { schema_version: 1, command: "stop", task_id: "", accepted: false, idempotent: false, error: { code: "mock", message: "not available in browser mock" } }; }, async CancelTaskForTab() { return { schema_version: 1, command: "cancel", task_id: "", accepted: false, idempotent: false, error: { code: "mock", message: "not available in browser mock" } }; }, diff --git a/desktop/frontend/src/lib/taskCatalogBridge.ts b/desktop/frontend/src/lib/taskCatalogBridge.ts new file mode 100644 index 0000000000..3de64b988d --- /dev/null +++ b/desktop/frontend/src/lib/taskCatalogBridge.ts @@ -0,0 +1,27 @@ +import type { ControlResult } from "./types"; +import type { + TaskActionRequest, TaskCatalogStatus, TaskEventPage, TaskEventPageRequest, TaskPage, TaskPageRequest, +} from "./taskCatalogTypes"; + +export interface TaskCatalogBindings { + ListTaskPage(req: TaskPageRequest): Promise; + ListTaskEventPage(req: TaskEventPageRequest): Promise; + StopTaskByKey(req: TaskActionRequest): Promise; + CancelTaskByKey(req: TaskActionRequest): Promise; + RequeueTaskByKey(req: TaskActionRequest): Promise; + OpenTaskSessionByKey(req: { projectKey: string; taskId: string }): Promise; + GetTaskCatalogStatus(): Promise; + RebuildTaskCatalog(): Promise; +} + +export function makeMockTaskCatalogBindings(): TaskCatalogBindings { + const unavailable = (command: string): ControlResult => ({ schema_version: 1, command, task_id: "", accepted: false, idempotent: false, error: { code: "mock", message: "not available in browser mock" } }); + return { + async ListTaskPage() { return { items: [], nextCursor: "", revision: 1, partial: false, staleCursor: false, status: await this.GetTaskCatalogStatus() }; }, + async ListTaskEventPage(req) { return { items: [], nextSequence: req.after, partial: false }; }, + async StopTaskByKey() { return unavailable("stop"); }, async CancelTaskByKey() { return unavailable("cancel"); }, + async RequeueTaskByKey() { return unavailable("requeue"); }, async OpenTaskSessionByKey() { return unavailable("open_session"); }, + async GetTaskCatalogStatus() { return { state: "ready", mode: "memory", revision: 1, indexed: 0, total: 0, pending: 0, failed: 0 }; }, + async RebuildTaskCatalog() {}, + }; +} diff --git a/desktop/frontend/src/lib/taskCatalogTypes.ts b/desktop/frontend/src/lib/taskCatalogTypes.ts new file mode 100644 index 0000000000..62d4136220 --- /dev/null +++ b/desktop/frontend/src/lib/taskCatalogTypes.ts @@ -0,0 +1,14 @@ +import type { TaskEvent, TaskSnapshot } from "./types"; + +export interface TaskPageRequest { + scope: "session" | "project" | "all" | string; tabId: string; projectKey: string; states: string[]; query: string; cursor: string; limit: number; +} +export interface TaskCatalogStatus { + state: string; mode: "disk" | "memory" | string; path?: string; revision: number; + indexed: number; total: number; pending: number; failed: number; lastError?: string; +} +export interface TaskCatalogItem { projectKey: string; projectLabel: string; task: TaskSnapshot } +export interface TaskPage { items: TaskCatalogItem[]; nextCursor: string; revision: number; partial: boolean; staleCursor: boolean; status: TaskCatalogStatus } +export interface TaskEventPageRequest { projectKey: string; taskId: string; after: number; limit: number } +export interface TaskEventPage { items: TaskEvent[]; nextSequence: number; partial: boolean } +export interface TaskActionRequest { projectKey: string; taskId: string; expectedVersion: number; reason: string; idempotencyKey: string } diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index dad184f46e..53bfdb6c07 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -2225,6 +2225,8 @@ export interface TaskSnapshot { error_summary?: string; } +export type { TaskActionRequest, TaskCatalogItem, TaskCatalogStatus, TaskEventPage, TaskEventPageRequest, TaskPage, TaskPageRequest } from "./taskCatalogTypes"; + export interface ControlResult { schema_version: number; command: string; diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index fadb7cfcc7..4e36f1ad34 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -2385,6 +2385,7 @@ export const en = { "palette.cmd.models": "Models", "palette.cmd.reloadRuntime": "Reload Runtime", "palette.cmd.usageStats": "Usage statistics", + "palette.cmd.taskCenter": "Task Center", "palette.group.remote": "Remote SSH", "palette.group.extensions": "Extensions", "ext.form.defaultTitle": "Extension request", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index ae6aeeea7e..d98e882835 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -3026,6 +3026,7 @@ export const zhTW: Record = { "palette.cmd.models": "模型", "palette.cmd.reloadRuntime": "重載執行階段", "palette.cmd.usageStats": "使用統計", + "palette.cmd.taskCenter": "任務中心", "palette.group.remote": "遠端 SSH", "palette.group.extensions": "擴充功能", "ext.form.defaultTitle": "擴充功能請求", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index cbac9aa5c3..e3d12d08bc 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -2388,6 +2388,7 @@ export const zh: Record = { "palette.cmd.models": "模型", "palette.cmd.reloadRuntime": "重载运行时", "palette.cmd.usageStats": "使用统计", + "palette.cmd.taskCenter": "任务中心", "palette.group.remote": "远程 SSH", "palette.group.extensions": "扩展", "ext.form.defaultTitle": "扩展请求", diff --git a/desktop/session_catalog.go b/desktop/session_catalog.go index 680ceee442..74e9484756 100644 --- a/desktop/session_catalog.go +++ b/desktop/session_catalog.go @@ -10,6 +10,7 @@ import ( "reasonix/internal/config" "reasonix/internal/sessioncatalog" + "reasonix/internal/taskcatalog" ) type SessionCatalogStatus struct { @@ -59,6 +60,13 @@ type ProjectTreeChangedV2 struct { Reason string `json:"reason"` } +func flushDesktopTaskCatalog(ctx context.Context) error { + if catalog := taskcatalog.Shared(); catalog != nil { + return catalog.Flush(ctx) + } + return nil +} + func sessionCatalogStatus(status sessioncatalog.Status) SessionCatalogStatus { return SessionCatalogStatus{ State: string(status.State), @@ -106,6 +114,11 @@ func (a *App) startSessionCatalog(rebuild bool) { defer a.catalogRebuilding.Store(false) path := sessioncatalog.DefaultPath() targets := a.sessionCatalogTargets() + projects := loadProjectsFile() + taskcatalog.RegisterSharedProject(globalWorkspaceRoot(), projects.GlobalTitle) + for _, project := range projects.Projects { + taskcatalog.RegisterSharedProject(project.Root, projectDisplayName(project)) + } if rebuild { if _, err := sessioncatalog.Rebuild(ctx, path, targets); err != nil && !errors.Is(err, context.Canceled) { slog.Warn("desktop: rebuild session catalog", "err", err) diff --git a/desktop/settings_app.go b/desktop/settings_app.go index a4553828cc..e4e0d19dd4 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -1952,6 +1952,7 @@ func (a *App) buildSettingReplacementController(tab *WorkspaceTab, snap tabRunti RuntimeReload: boot.RuntimeReload{ForceFullRebuild: reload}, AutoPricingCurrency: a.desktopAutoPricingCurrency(), StatsSource: "desktop", + TaskStore: a.taskStore(), Sink: snap.sink, WorkspaceRoot: snap.workspaceRoot, SessionDir: sessionDirForSnapshot(snap), diff --git a/desktop/tabs.go b/desktop/tabs.go index d2ccf42d45..b0889ad222 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -3899,6 +3899,7 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession RequireKey: false, AutoPricingCurrency: a.desktopAutoPricingCurrency(), StatsSource: "desktop", + TaskStore: a.taskStore(), Sink: sink, WorkspaceRoot: root, SessionDir: sessionDir, diff --git a/desktop/task_catalog.go b/desktop/task_catalog.go new file mode 100644 index 0000000000..2a75123029 --- /dev/null +++ b/desktop/task_catalog.go @@ -0,0 +1,281 @@ +package main + +import ( + "fmt" + "strings" + + "reasonix/internal/agent" + "reasonix/internal/control" + "reasonix/internal/taskcatalog" + "reasonix/internal/taskmonitor" +) + +type TaskPageRequest struct { + Scope string `json:"scope"` + TabID string `json:"tabId"` + ProjectKey string `json:"projectKey"` + States []string `json:"states"` + Query string `json:"query"` + Cursor string `json:"cursor"` + Limit int `json:"limit"` +} + +type TaskCatalogItem = taskcatalog.Item +type TaskCatalogStatus = taskcatalog.Status + +type TaskPage struct { + Items []TaskCatalogItem `json:"items"` + NextCursor string `json:"nextCursor"` + Revision uint64 `json:"revision"` + Partial bool `json:"partial"` + StaleCursor bool `json:"staleCursor"` + Status TaskCatalogStatus `json:"status"` +} + +type TaskEventPageRequest struct { + ProjectKey string `json:"projectKey"` + TaskID string `json:"taskId"` + After int `json:"after"` + Limit int `json:"limit"` +} + +type TaskEventPage = taskcatalog.EventPage + +type TaskActionRequest struct { + ProjectKey string `json:"projectKey"` + TaskID string `json:"taskId"` + ExpectedVersion uint64 `json:"expectedVersion"` + Reason string `json:"reason"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type TaskOpenRequest struct { + ProjectKey string `json:"projectKey"` + TaskID string `json:"taskId"` +} + +func (a *App) GetTaskCatalogStatus() TaskCatalogStatus { + if catalog := taskcatalog.Shared(); catalog != nil { + return catalog.Status() + } + return TaskCatalogStatus{State: "opening", Mode: "memory", Pending: 1} +} + +func (a *App) taskProjectKeys(req TaskPageRequest) ([]string, string, error) { + switch strings.TrimSpace(req.Scope) { + case "session": + target, err := a.taskMonitorTargetForTab(req.TabID) + if err != nil { + return nil, "", err + } + key := taskcatalog.RegisterSharedProject(target.projectDir, workspaceName(target.projectDir)) + return []string{key}, target.sessionID, nil + case "project", "": + key := strings.TrimSpace(req.ProjectKey) + if key == "" { + root := a.projectDir() + key = taskcatalog.RegisterSharedProject(root, workspaceName(root)) + } else if !a.allowedTaskProjectKey(key) { + return nil, "", fmt.Errorf("unknown project key") + } + return []string{key}, "", nil + case "all": + projects := loadProjectsFile() + keys := []string{taskcatalog.RegisterSharedProject(globalWorkspaceRoot(), projects.GlobalTitle)} + for _, project := range projects.Projects { + keys = append(keys, taskcatalog.RegisterSharedProject(project.Root, projectDisplayName(project))) + } + return keys, "", nil + default: + return nil, "", fmt.Errorf("unknown task scope %q", req.Scope) + } +} + +func (a *App) allowedTaskProjectKey(key string) bool { + key = strings.TrimSpace(key) + if key == "" { + return false + } + allowedRoots := []string{globalWorkspaceRoot(), a.projectDir()} + for _, project := range loadProjectsFile().Projects { + allowedRoots = append(allowedRoots, project.Root) + } + for _, root := range allowedRoots { + if taskcatalog.ProjectKey(root) == key { + return true + } + } + return false +} + +func (a *App) ListTaskPage(req TaskPageRequest) TaskPage { + status := a.GetTaskCatalogStatus() + out := TaskPage{Items: []TaskCatalogItem{}, Status: status, Partial: true} + catalog := taskcatalog.Shared() + if catalog == nil { + return out + } + keys, sessionID, err := a.taskProjectKeys(req) + if err != nil { + out.Status.LastError = err.Error() + return out + } + for _, key := range keys { + if _, ok, lookupErr := catalog.Project(a.bootContext(), key); lookupErr != nil || !ok { + out.Status.LastError = "unknown project key" + return out + } + } + page, err := catalog.ListPage(a.bootContext(), taskcatalog.PageRequest{ProjectKeys: keys, SessionID: sessionID, + States: req.States, Query: req.Query, Cursor: req.Cursor, Limit: req.Limit}) + if err != nil { + out.Status.LastError = err.Error() + return out + } + a.overlayTaskCatalogRuntime(page.Items) + return TaskPage{Items: page.Items, NextCursor: page.NextCursor, Revision: page.Revision, + Partial: page.Partial, StaleCursor: page.StaleCursor, Status: page.Status} +} + +func (a *App) overlayTaskCatalogRuntime(items []TaskCatalogItem) { + type controllerRef struct { + projectKey string + sessionID string + ctrl control.SessionAPI + } + a.mu.RLock() + tabs := a.runtimeTabsLocked() + controllers := make([]controllerRef, 0, len(tabs)) + for _, tab := range tabs { + if tab == nil || tab.Ctrl == nil { + continue + } + root := strings.TrimSpace(tab.WorkspaceRoot) + if root == "" { + root = globalWorkspaceRoot() + } + controllers = append(controllers, controllerRef{projectKey: taskcatalog.ProjectKey(root), ctrl: tab.Ctrl}) + } + a.mu.RUnlock() + for i := range controllers { + controllers[i].sessionID = agent.BranchID(controllers[i].ctrl.SessionPath()) + } + + type runtimeValue struct{ status string } + running := map[string]runtimeValue{} + for _, controller := range controllers { + for _, job := range controller.ctrl.Jobs() { + key := controller.projectKey + "\x00" + controller.sessionID + "\x00" + job.ID + running[key] = runtimeValue{status: job.Status} + } + } + for i := range items { + key := items[i].ProjectKey + "\x00" + items[i].Task.SessionID + "\x00" + items[i].Task.JobID + value, ok := running[key] + if !ok { + continue + } + items[i].Task.RuntimeState = taskmonitor.RuntimeStateAlive + switch value.status { + case "running": + items[i].Task.State = taskmonitor.TaskStateRunning + case "done": + items[i].Task.RuntimeState = taskmonitor.RuntimeStateExited + items[i].Task.State = taskmonitor.TaskStateSucceeded + case "failed": + items[i].Task.RuntimeState = taskmonitor.RuntimeStateExited + items[i].Task.State = taskmonitor.TaskStateFailed + case "killed", "interrupted": + items[i].Task.RuntimeState = taskmonitor.RuntimeStateExited + items[i].Task.State = taskmonitor.TaskStateCancelled + } + } +} + +func (a *App) ListTaskEventPage(req TaskEventPageRequest) TaskEventPage { + out := TaskEventPage{Items: []taskmonitor.TaskEvent{}, NextSequence: req.After} + if !a.allowedTaskProjectKey(req.ProjectKey) { + out.Partial = true + return out + } + catalog := taskcatalog.Shared() + if catalog == nil { + out.Partial = true + return out + } + page, err := catalog.ListEventPage(a.bootContext(), req.ProjectKey, req.TaskID, req.After, req.Limit) + if err != nil { + out.Partial = true + return out + } + return page +} + +func (a *App) taskActionProject(key string) (taskcatalog.Project, error) { + if !a.allowedTaskProjectKey(key) { + return taskcatalog.Project{}, fmt.Errorf("unknown project key") + } + catalog := taskcatalog.Shared() + if catalog == nil { + return taskcatalog.Project{}, fmt.Errorf("task catalog is still opening") + } + project, ok, err := catalog.Project(a.bootContext(), strings.TrimSpace(key)) + if err != nil { + return project, err + } + if !ok { + return project, fmt.Errorf("unknown project key") + } + return project, nil +} + +func (a *App) StopTaskByKey(req TaskActionRequest) (taskmonitor.ControlResult, error) { + project, err := a.taskActionProject(req.ProjectKey) + if err != nil { + return taskmonitor.ControlResult{}, err + } + return a.taskControl().StopTaskWithKiller(a.bootContext(), project.Root, req.TaskID, req.ExpectedVersion, req.Reason, req.IdempotencyKey, + desktopTaskJobKiller{app: a, projectDir: project.Root}) +} + +func (a *App) CancelTaskByKey(req TaskActionRequest) (taskmonitor.ControlResult, error) { + project, err := a.taskActionProject(req.ProjectKey) + if err != nil { + return taskmonitor.ControlResult{}, err + } + return a.taskControl().CancelTaskWithKiller(a.bootContext(), project.Root, req.TaskID, req.ExpectedVersion, req.Reason, req.IdempotencyKey, + desktopTaskJobKiller{app: a, projectDir: project.Root}) +} + +func (a *App) RequeueTaskByKey(req TaskActionRequest) (taskmonitor.ControlResult, error) { + project, err := a.taskActionProject(req.ProjectKey) + if err != nil { + return taskmonitor.ControlResult{}, err + } + return a.taskControl().RequeueTask(a.bootContext(), project.Root, req.TaskID, req.ExpectedVersion, req.IdempotencyKey) +} + +func (a *App) OpenTaskSessionByKey(req TaskOpenRequest) (taskmonitor.ControlResult, error) { + project, err := a.taskActionProject(req.ProjectKey) + if err != nil { + return taskmonitor.ControlResult{}, err + } + return a.taskControl().OpenTaskSession(a.bootContext(), project.Root, req.TaskID) +} + +func (a *App) RebuildTaskCatalog() error { + projects := loadProjectsFile() + items := []struct{ root, label string }{{globalWorkspaceRoot(), projects.GlobalTitle}} + for _, project := range projects.Projects { + items = append(items, struct{ root, label string }{project.Root, projectDisplayName(project)}) + } + for _, item := range items { + taskcatalog.RegisterSharedProject(item.root, item.label) + if catalog := taskcatalog.Shared(); catalog != nil { + if err := catalog.RequestReconcileProject(a.bootContext(), item.root, item.label); err != nil { + return err + } + } + } + return nil +} diff --git a/docs/TASK_CATALOG.md b/docs/TASK_CATALOG.md new file mode 100644 index 0000000000..9526601840 --- /dev/null +++ b/docs/TASK_CATALOG.md @@ -0,0 +1,26 @@ +# Task Catalog + +Reasonix stores the cross-project task projection at +`/task-catalog/v1.sqlite`. Task snapshots, event JSONL, idempotency +files, file locks, version CAS, and leases remain authoritative. SQLite is never +used to accept stop, cancel, requeue, or open-session commands. + +An observed `FileStore` sends non-blocking snapshot and event hints only after +the per-task lock is released. Snapshots are indexed eagerly in bounded batches; +event logs are indexed lazily when a task expands, an event query arrives, or a +new event is appended. Byte-offset and sequence checkpoints resume after an +interruption. Corrupt snapshots and event lines degrade only that task. + +The Desktop task center pages across current session, project, or all registered +projects. Project keys are full SHA-256 hashes of canonical saved roots and are +resolved before every action. The control service then re-reads the authoritative +snapshot. Current process jobs and controller state always overlay catalog data, +and expired leases are reconciled at read time without rewriting snapshots. + +```sh +reasonix doctor catalogs [--json] +reasonix catalogs reindex tasks [--project PATH ...] [--json] +``` + +Catalog corruption or partial indexing does not disable task control. Reindexing +replaces only the disposable projection. diff --git a/docs/TASK_CATALOG.zh-CN.md b/docs/TASK_CATALOG.zh-CN.md new file mode 100644 index 0000000000..92bd58df1e --- /dev/null +++ b/docs/TASK_CATALOG.zh-CN.md @@ -0,0 +1,21 @@ +# Task Catalog + +Reasonix 将跨项目任务投影保存到 `/task-catalog/v1.sqlite`。任务 snapshot、 +event JSONL、idempotency 文件、文件锁、version CAS 和 lease 继续作为权威数据。SQLite +绝不参与 stop、cancel、requeue 或 open-session 的接受判断。 + +Observed `FileStore` 只在 per-task lock 释放后发送非阻塞 snapshot/event 提示。Snapshot +按有界批次优先索引;event log 仅在任务展开、事件查询或追加新事件时惰性索引。byte +offset 和 sequence checkpoint 可在强退后续传;坏 snapshot 或 event line 只降级单个任务。 + +Desktop Task Center 对当前会话、当前项目或全部已注册项目分页。Project key 是 canonical +已保存 root 的完整 SHA-256,每次 action 都先解析 key,再由 control service 重读权威 +snapshot。当前进程的 jobs/controller 状态始终覆盖 catalog;过期 lease 只在读取时对账, +不回写 snapshot。 + +```sh +reasonix doctor catalogs [--json] +reasonix catalogs reindex tasks [--project PATH ...] [--json] +``` + +Catalog 损坏或未完成索引不会禁用任务控制;reindex 只替换可丢弃投影。 diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 79ba49c295..c491df1606 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -60,6 +60,7 @@ import ( "reasonix/internal/sessiontemp" "reasonix/internal/skill" "reasonix/internal/stats" + "reasonix/internal/taskmonitor" "reasonix/internal/tool" "reasonix/internal/tool/builtin" "reasonix/internal/tool/sessiontool" @@ -128,6 +129,7 @@ type Options struct { // StatsSource labels this frontend's usage records (desktop/cli/serve). // Empty disables usage recording for this controller. StatsSource string + TaskStore taskmonitor.WriteStore // Authoritative store, never a SQLite catalog. // ExtraPlugins are session-scoped MCP servers supplied by a host transport // (for example ACP session/new). They are connected eagerly for this // controller but are not persisted to reasonix.toml. @@ -1923,6 +1925,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) { BalanceKey: entry.APIKey(), BalanceClient: balanceClient, Jobs: jm, + TaskStore: opts.TaskStore, WorkspaceLease: workspaceLease, Registry: reg, PluginCtx: ctx, diff --git a/internal/cli/catalogs_tasks.go b/internal/cli/catalogs_tasks.go new file mode 100644 index 0000000000..d573664497 --- /dev/null +++ b/internal/cli/catalogs_tasks.go @@ -0,0 +1,50 @@ +package cli + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "reasonix/internal/taskcatalog" +) + +func init() { + registerCatalogCommand(catalogCommand{name: "tasks", path: taskcatalog.DefaultPath, reindex: reindexTaskCatalog}) +} + +func reindexTaskCatalog(args []string) int { + fs := flag.NewFlagSet("catalogs reindex tasks", flag.ContinueOnError) + var projects stringListFlag + jsonOut := fs.Bool("json", false, "print status as JSON") + fs.Var(&projects, "project", "project root to index; repeat for multiple projects") + if code, ok := parseCommandFlags(fs, args); !ok { + return code + } + if len(projects) == 0 { + for _, target := range defaultSessionCatalogTargets() { + if strings.TrimSpace(target.WorkspaceRoot) != "" { + projects = append(projects, target.WorkspaceRoot) + } + } + } + catalog, err := taskcatalog.Open(context.Background(), "") + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + defer catalog.Close(context.Background()) + for _, root := range projects { + project, err := catalog.RegisterProject(context.Background(), root, filepath.Base(root)) + if err == nil { + err = catalog.ReconcileProject(context.Background(), project) + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + } + return printCatalogStatus(catalog.Status(), *jsonOut) +} diff --git a/internal/control/controller.go b/internal/control/controller.go index 5619557d16..958ec263a8 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -461,6 +461,10 @@ type Options struct { BalanceClient *http.Client // Jobs is the session-scoped background-job manager (nil disables background jobs). Jobs *jobs.Manager + // TaskStore remains a FileStore-compatible authority. Desktop injects one + // observed instance so recorder and task-control APIs share post-commit + // projection hints; nil preserves the ordinary FileStore. + TaskStore taskmonitor.WriteStore // WorkspaceLease is the Delivery writer owner shared with the executor. WorkspaceLease *workspacelease.Owner // Registry is the executor's live tool set, and PluginCtx the session-scoped @@ -666,8 +670,12 @@ func New(opts Options) *Controller { // must never affect the agent pipeline. The session id is resolved lazily // because the session path is only fixed once the first turn begins. if c.jobs != nil && c.workspaceRoot != "" { + taskStore := opts.TaskStore + if taskStore == nil { + taskStore = taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks")) + } c.jobs.SetTaskRecorder(taskmonitor.NewTaskRecorder( - taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks")), + taskStore, c.workspaceRoot, func() string { return c.parentSessionID() }, )) diff --git a/internal/taskcatalog/benchmark_test.go b/internal/taskcatalog/benchmark_test.go new file mode 100644 index 0000000000..682ebdd193 --- /dev/null +++ b/internal/taskcatalog/benchmark_test.go @@ -0,0 +1,50 @@ +package taskcatalog + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "testing" + "time" + + "reasonix/internal/taskmonitor" +) + +func BenchmarkWarmFirstPageHundredThousandTasks(b *testing.B) { + catalog, err := Open(context.Background(), filepath.Join(b.TempDir(), "tasks.sqlite")) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = catalog.Close(context.Background()) }) + root := b.TempDir() + project := Project{Key: ProjectKey(root), Root: root, Label: "Benchmark"} + if _, err := catalog.db.Exec(`INSERT INTO task_projects(project_key,project_root,project_label,state) VALUES(?,?,?,'ready')`, + project.Key, project.Root, project.Label); err != nil { + b.Fatal(err) + } + tx, err := catalog.db.Begin() + if err != nil { + b.Fatal(err) + } + now := time.Now().UTC() + for i := range 100_000 { + task := taskmonitor.TaskSnapshot{SchemaVersion: 1, TaskID: fmt.Sprintf("task-%06d", i), SessionID: "session", + State: taskmonitor.TaskStateSucceeded, Version: 1, CreatedAt: now, UpdatedAt: now.Add(time.Duration(i) * time.Millisecond)} + raw, _ := json.Marshal(task) + if _, err := tx.Exec(`INSERT INTO task_snapshots(project_key,task_id,session_id,state,version,created_at,updated_at,snapshot_json) + VALUES(?,?,?,?,?,?,?,?)`, project.Key, task.TaskID, task.SessionID, task.State, task.Version, + task.CreatedAt.UnixMilli(), task.UpdatedAt.UnixMilli(), raw); err != nil { + b.Fatal(err) + } + } + if err := tx.Commit(); err != nil { + b.Fatal(err) + } + b.ResetTimer() + for range b.N { + if _, err := catalog.ListPage(context.Background(), PageRequest{ProjectKeys: []string{project.Key}, Limit: 50}); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/taskcatalog/catalog.go b/internal/taskcatalog/catalog.go new file mode 100644 index 0000000000..61481d0741 --- /dev/null +++ b/internal/taskcatalog/catalog.go @@ -0,0 +1,666 @@ +// Package taskcatalog maintains a disposable cross-project projection of the +// authoritative taskmonitor FileStore. +package taskcatalog + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "reasonix/internal/config" + "reasonix/internal/projectiondb" + "reasonix/internal/taskmonitor" +) + +const ( + SchemaVersion = 1 + DefaultLimit = 50 + MaxLimit = 200 + missingGrace = 30 * time.Second +) + +type Status struct { + State string `json:"state"` + Mode projectiondb.Mode `json:"mode"` + Path string `json:"path,omitempty"` + Revision uint64 `json:"revision"` + Indexed int64 `json:"indexed"` + Total int64 `json:"total"` + Pending int64 `json:"pending"` + Failed int64 `json:"failed"` + LastError string `json:"lastError,omitempty"` +} + +type Project struct { + Key string `json:"projectKey"` + Root string `json:"projectRoot"` + Label string `json:"projectLabel"` +} + +type PageRequest struct { + ProjectKeys []string + SessionID string + States []string + Query string + Cursor string + Limit int +} + +type Item struct { + ProjectKey string `json:"projectKey"` + ProjectLabel string `json:"projectLabel"` + Task taskmonitor.TaskSnapshot `json:"task"` +} + +type Page struct { + Items []Item `json:"items"` + NextCursor string `json:"nextCursor"` + Revision uint64 `json:"revision"` + Partial bool `json:"partial"` + StaleCursor bool `json:"staleCursor"` + Status Status `json:"status"` +} + +type EventPage struct { + Items []taskmonitor.TaskEvent `json:"items"` + NextSequence int `json:"nextSequence"` + Partial bool `json:"partial"` +} + +type cursor struct { + Revision uint64 `json:"r"` + Updated int64 `json:"u"` + Project string `json:"p"` + Task string `json:"t"` +} + +type request struct { + projectRoot string + taskID string + events bool + flush chan struct{} +} + +type Catalog struct { + db *sql.DB + store *taskmonitor.FileStore + ctx context.Context + cancel context.CancelFunc + queue chan request + dirtyWake chan struct{} + dirtyProjects sync.Map + wg sync.WaitGroup + revision atomic.Uint64 + statusMu sync.RWMutex + status Status + reconcileMu sync.Mutex + reconciling map[string]bool + registered map[string]bool +} + +func DefaultPath() string { return filepath.Join(config.CacheDir(), "task-catalog", "v1.sqlite") } + +func ProjectKey(root string) string { + root = filepath.Clean(strings.TrimSpace(root)) + if abs, err := filepath.Abs(root); err == nil { + root = abs + } + sum := sha256.Sum256([]byte(root)) + return hex.EncodeToString(sum[:]) +} + +const schema = ` +CREATE TABLE task_state(id INTEGER PRIMARY KEY CHECK(id=1),revision INTEGER NOT NULL DEFAULT 0); +INSERT INTO task_state(id,revision) VALUES(1,0); +CREATE TABLE task_projects(project_key TEXT PRIMARY KEY,project_root TEXT UNIQUE NOT NULL,project_label TEXT NOT NULL DEFAULT '', + signature TEXT NOT NULL DEFAULT '',scan_generation INTEGER NOT NULL DEFAULT 0,scan_cursor TEXT NOT NULL DEFAULT '',state TEXT NOT NULL DEFAULT 'pending', + error TEXT NOT NULL DEFAULT '',indexed INTEGER NOT NULL DEFAULT 0,total INTEGER NOT NULL DEFAULT 0,completed_at INTEGER NOT NULL DEFAULT 0); +CREATE TABLE task_snapshots(project_key TEXT NOT NULL,task_id TEXT NOT NULL,session_id TEXT NOT NULL DEFAULT '',job_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT '',label TEXT NOT NULL DEFAULT '',state TEXT NOT NULL,runtime_state TEXT NOT NULL DEFAULT '',runtime_lease_until INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,error_code TEXT NOT NULL DEFAULT '',snapshot_fingerprint TEXT NOT NULL DEFAULT '', + snapshot_json BLOB NOT NULL,health TEXT NOT NULL DEFAULT 'ok',missing_since INTEGER NOT NULL DEFAULT 0,seen_generation INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(project_key,task_id),FOREIGN KEY(project_key) REFERENCES task_projects(project_key) ON DELETE CASCADE); +CREATE TABLE task_event_sources(project_key TEXT NOT NULL,task_id TEXT NOT NULL,path TEXT NOT NULL,size INTEGER NOT NULL DEFAULT 0,indexed_offset INTEGER NOT NULL DEFAULT 0, + fingerprint TEXT NOT NULL DEFAULT '',state TEXT NOT NULL DEFAULT 'pending',error TEXT NOT NULL DEFAULT '',PRIMARY KEY(project_key,task_id)); +CREATE TABLE task_events(project_key TEXT NOT NULL,task_id TEXT NOT NULL,sequence INTEGER NOT NULL,timestamp INTEGER NOT NULL,event_type TEXT NOT NULL, + state TEXT NOT NULL,runtime_state TEXT NOT NULL DEFAULT '',event_json BLOB NOT NULL,PRIMARY KEY(project_key,task_id,sequence)); +CREATE INDEX idx_task_project_page ON task_snapshots(project_key,updated_at DESC,task_id); +CREATE INDEX idx_task_state_page ON task_snapshots(state,updated_at DESC,project_key,task_id); +CREATE INDEX idx_task_session ON task_snapshots(session_id,updated_at DESC,task_id); +CREATE INDEX idx_task_events_page ON task_events(project_key,task_id,sequence); +` + +func migrations() []projectiondb.Migration { + return []projectiondb.Migration{{Version: 1, Apply: func(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, schema) + return err + }}} +} + +func Open(ctx context.Context, path string) (*Catalog, error) { + if path == "" { + path = DefaultPath() + } + handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{Path: path, MemoryName: "task-catalog", Migrations: migrations(), MaxOpenConns: 4}) + if err != nil { + return nil, err + } + workerCtx, cancel := context.WithCancel(context.Background()) + c := &Catalog{db: handle.DB, store: taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks")), ctx: workerCtx, cancel: cancel, + queue: make(chan request, 1024), dirtyWake: make(chan struct{}, 1), reconciling: map[string]bool{}, registered: map[string]bool{}, status: Status{State: string(handle.Status.State), Mode: handle.Status.Mode, Path: handle.Status.Path, LastError: handle.Status.LastError}} + var revision uint64 + _ = c.db.QueryRowContext(ctx, `SELECT revision FROM task_state WHERE id=1`).Scan(&revision) + c.revision.Store(revision) + c.refresh(ctx) + c.wg.Add(1) + go c.worker() + return c, nil +} + +func (c *Catalog) ObservedStore() *taskmonitor.FileStore { + return taskmonitor.NewObservedFileStore(filepath.Join(".reasonix", "tasks"), c) +} + +func (c *Catalog) SnapshotChanged(projectRoot, taskID string) { + c.enqueue(request{projectRoot: projectRoot, taskID: taskID}) +} +func (c *Catalog) EventsChanged(projectRoot, taskID string) { + c.enqueue(request{projectRoot: projectRoot, taskID: taskID, events: true}) +} + +func (c *Catalog) enqueue(req request) { + select { + case c.queue <- req: + default: + c.dirtyProjects.Store(req.projectRoot, true) + c.wakeDirty() + } +} + +func (c *Catalog) worker() { + defer c.wg.Done() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + if root, ok := c.takeDirtyProject(); ok { + if project, exists, err := c.projectByRoot(c.ctx, root); err == nil && exists { + _ = c.ReconcileProject(c.ctx, project) + } else if err == nil { + _, _ = c.RegisterProject(c.ctx, root, filepath.Base(root)) + } + continue + } + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + c.markRegisteredProjectsDirty() + case <-c.dirtyWake: + case req := <-c.queue: + if req.flush != nil { + close(req.flush) + continue + } + if req.events { + _ = c.indexEvents(c.ctx, req.projectRoot, req.taskID) + } else { + _ = c.indexSnapshot(c.ctx, req.projectRoot, req.taskID, 0) + } + } + } +} + +func (c *Catalog) takeDirtyProject() (string, bool) { + var root string + c.dirtyProjects.Range(func(key, _ any) bool { + root, _ = key.(string) + c.dirtyProjects.Delete(key) + return false + }) + return root, root != "" +} + +func (c *Catalog) markRegisteredProjectsDirty() { + rows, err := c.db.QueryContext(c.ctx, `SELECT project_root FROM task_projects`) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var root string + if rows.Scan(&root) == nil { + c.dirtyProjects.Store(root, true) + } + } +} + +func (c *Catalog) wakeDirty() { + select { + case c.dirtyWake <- struct{}{}: + default: + } +} + +func (c *Catalog) Flush(ctx context.Context) error { + done := make(chan struct{}) + select { + case c.queue <- request{flush: done}: + case <-ctx.Done(): + return ctx.Err() + } + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *Catalog) RegisterProject(ctx context.Context, root, label string) (Project, error) { + root = filepath.Clean(strings.TrimSpace(root)) + if abs, err := filepath.Abs(root); err == nil { + root = abs + } + project := Project{Key: ProjectKey(root), Root: root, Label: strings.TrimSpace(label)} + _, err := c.db.ExecContext(ctx, `INSERT INTO task_projects(project_key,project_root,project_label,state) VALUES(?,?,?,'pending') + ON CONFLICT(project_key) DO UPDATE SET project_root=excluded.project_root,project_label=excluded.project_label`, project.Key, project.Root, project.Label) + c.reconcileMu.Lock() + firstRegistration := !c.registered[project.Key] + c.registered[project.Key] = true + c.reconcileMu.Unlock() + if err == nil && firstRegistration { + c.scheduleReconcile(project) + } + return project, err +} + +// RequestReconcileProject forces a background authority scan even when the +// project was already registered by an earlier page request. +func (c *Catalog) RequestReconcileProject(ctx context.Context, root, label string) error { + project, err := c.RegisterProject(ctx, root, label) + if err != nil { + return err + } + c.scheduleReconcile(project) + return nil +} + +func (c *Catalog) scheduleReconcile(project Project) { + c.reconcileMu.Lock() + if c.reconciling[project.Key] { + c.reconcileMu.Unlock() + return + } + c.reconciling[project.Key] = true + c.reconcileMu.Unlock() + go func() { + defer func() { + c.reconcileMu.Lock() + delete(c.reconciling, project.Key) + c.reconcileMu.Unlock() + }() + _ = c.ReconcileProject(c.ctx, project) + }() +} + +func (c *Catalog) ReconcileProject(ctx context.Context, project Project) error { + tasks, err := c.store.ListTasks(ctx, project.Root) + if err != nil { + return err + } + var generation int64 + err = c.db.QueryRowContext(ctx, `UPDATE task_projects SET scan_generation=scan_generation+1,state='scanning',total=? WHERE project_key=? RETURNING scan_generation`, len(tasks), project.Key).Scan(&generation) + if err != nil { + return err + } + for _, task := range tasks { + if err := c.upsertSnapshot(ctx, project, task, generation); err != nil { + continue + } + } + now := time.Now().UnixMilli() + _, _ = c.db.ExecContext(ctx, `UPDATE task_snapshots SET missing_since=CASE WHEN missing_since=0 THEN ? ELSE missing_since END,health='missing' + WHERE project_key=? AND seen_generation<>?`, now, project.Key, generation) + cutoff := now - missingGrace.Milliseconds() + _, _ = c.db.ExecContext(ctx, `DELETE FROM task_events WHERE project_key=? AND task_id IN ( + SELECT task_id FROM task_snapshots WHERE project_key=? AND seen_generation<>? AND missing_since>0 AND missing_since<=? + )`, project.Key, project.Key, generation, cutoff) + _, _ = c.db.ExecContext(ctx, `DELETE FROM task_event_sources WHERE project_key=? AND task_id IN ( + SELECT task_id FROM task_snapshots WHERE project_key=? AND seen_generation<>? AND missing_since>0 AND missing_since<=? + )`, project.Key, project.Key, generation, cutoff) + _, _ = c.db.ExecContext(ctx, `DELETE FROM task_snapshots WHERE project_key=? AND seen_generation<>? AND missing_since>0 AND missing_since<=?`, + project.Key, generation, cutoff) + tx, beginErr := c.db.BeginTx(ctx, nil) + if beginErr != nil { + return beginErr + } + if _, err = tx.ExecContext(ctx, `UPDATE task_projects SET state='ready',indexed=?,completed_at=? WHERE project_key=?`, len(tasks), now, project.Key); err != nil { + _ = tx.Rollback() + return err + } + revision, err := bump(ctx, tx) + if err != nil { + _ = tx.Rollback() + return err + } + if err = tx.Commit(); err != nil { + return err + } + c.revision.Store(revision) + c.refresh(context.Background()) + return nil +} + +func (c *Catalog) indexSnapshot(ctx context.Context, root, taskID string, generation int64) error { + project, ok, err := c.projectByRoot(ctx, root) + if err != nil { + return err + } + if !ok { + project, err = c.RegisterProject(ctx, root, filepath.Base(root)) + if err != nil { + return err + } + } + task, err := c.store.GetTask(ctx, project.Root, taskID) + if err != nil || task == nil { + return err + } + return c.upsertSnapshot(ctx, project, *task, generation) +} + +func (c *Catalog) upsertSnapshot(ctx context.Context, project Project, task taskmonitor.TaskSnapshot, generation int64) error { + b, err := json.Marshal(task) + if err != nil { + return err + } + hash := sha256.Sum256(b) + lease := int64(0) + if !task.RuntimeLeaseUntil.IsZero() { + lease = task.RuntimeLeaseUntil.UnixMilli() + } + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO task_snapshots(project_key,task_id,session_id,job_id,state,runtime_state,runtime_lease_until,version, + created_at,updated_at,error_code,snapshot_fingerprint,snapshot_json,health,missing_since,seen_generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,'ok',0,?) + ON CONFLICT(project_key,task_id) DO UPDATE SET session_id=excluded.session_id,job_id=excluded.job_id,state=excluded.state, + runtime_state=excluded.runtime_state,runtime_lease_until=excluded.runtime_lease_until,version=excluded.version,created_at=excluded.created_at, + updated_at=excluded.updated_at,error_code=excluded.error_code,snapshot_fingerprint=excluded.snapshot_fingerprint,snapshot_json=excluded.snapshot_json, + health='ok',missing_since=0,seen_generation=excluded.seen_generation`, project.Key, task.TaskID, task.SessionID, task.JobID, task.State, + task.RuntimeState, lease, task.Version, task.CreatedAt.UnixMilli(), task.UpdatedAt.UnixMilli(), task.ErrorCode, hex.EncodeToString(hash[:]), b, generation) + if err != nil { + _ = tx.Rollback() + return err + } + revision, err := bump(ctx, tx) + if err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + c.revision.Store(revision) + c.refresh(context.Background()) + return nil +} + +func (c *Catalog) indexEvents(ctx context.Context, root, taskID string) error { + if taskID == "" || filepath.Base(taskID) != taskID || strings.ContainsAny(taskID, `/\\`) { + return errors.New("invalid task id") + } + project, ok, err := c.projectByRoot(ctx, root) + if err != nil || !ok { + return err + } + path := filepath.Join(project.Root, ".reasonix", "tasks", taskID, "events.jsonl") + info, statErr := os.Stat(path) + if errors.Is(statErr, os.ErrNotExist) { + return nil + } + if statErr != nil { + return statErr + } + fingerprint := fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano()) + var oldPath, oldFingerprint string + var oldSize, offset int64 + err = c.db.QueryRowContext(ctx, `SELECT path,size,indexed_offset,fingerprint FROM task_event_sources WHERE project_key=? AND task_id=?`, + project.Key, taskID).Scan(&oldPath, &oldSize, &offset, &oldFingerprint) + reset := errors.Is(err, sql.ErrNoRows) || oldPath != path || info.Size() < oldSize || info.Size() == oldSize && fingerprint != oldFingerprint + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if !reset && info.Size() == oldSize && fingerprint == oldFingerprint { + return nil + } + if reset { + offset = 0 + } + tail, err := c.store.ReadEventTail(ctx, project.Root, taskID, offset) + if err != nil { + return err + } + reset = reset || tail.Reset + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if reset { + if _, err := tx.ExecContext(ctx, `DELETE FROM task_events WHERE project_key=? AND task_id=?`, project.Key, taskID); err != nil { + _ = tx.Rollback() + return err + } + } + for _, event := range tail.Items { + b, _ := json.Marshal(event) + if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO task_events(project_key,task_id,sequence,timestamp,event_type,state,runtime_state,event_json) + VALUES(?,?,?,?,?,?,?,?)`, project.Key, taskID, event.Sequence, event.Timestamp.UnixMilli(), event.EventType, event.State, event.RuntimeState, b); err != nil { + _ = tx.Rollback() + return err + } + } + _, err = tx.ExecContext(ctx, `INSERT INTO task_event_sources(project_key,task_id,path,size,indexed_offset,fingerprint,state,error) + VALUES(?,?,?,?,?,?,'ready','') ON CONFLICT(project_key,task_id) DO UPDATE SET path=excluded.path,size=excluded.size, + indexed_offset=excluded.indexed_offset,fingerprint=excluded.fingerprint,state='ready',error=''`, project.Key, taskID, path, + info.Size(), tail.NextOffset, fingerprint) + if err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +func (c *Catalog) ListPage(ctx context.Context, req PageRequest) (Page, error) { + status := c.Status() + out := Page{Items: []Item{}, Revision: c.revision.Load(), Partial: status.Pending > 0, Status: status} + limit := req.Limit + if limit <= 0 { + limit = DefaultLimit + } + if limit > MaxLimit { + limit = MaxLimit + } + cur, err := decodeCursor(req.Cursor) + if err != nil { + return out, err + } + if cur != nil && cur.Revision != out.Revision { + out.StaleCursor = true + return out, nil + } + where := []string{`s.health='ok'`, `s.missing_since=0`} + args := []any{} + if len(req.ProjectKeys) > 0 { + parts := make([]string, len(req.ProjectKeys)) + for i, key := range req.ProjectKeys { + parts[i] = "?" + args = append(args, key) + } + where = append(where, `s.project_key IN (`+strings.Join(parts, ",")+`)`) + } + if req.SessionID != "" { + where = append(where, `s.session_id=?`) + args = append(args, req.SessionID) + } + if len(req.States) > 0 { + parts := make([]string, len(req.States)) + for i, state := range req.States { + parts[i] = "?" + args = append(args, state) + } + where = append(where, `s.state IN (`+strings.Join(parts, ",")+`)`) + } + if query := strings.ToLower(strings.TrimSpace(req.Query)); query != "" { + where = append(where, `(lower(s.task_id) LIKE ? OR lower(s.session_id) LIKE ? OR lower(s.error_code) LIKE ?)`) + like := "%" + query + "%" + args = append(args, like, like, like) + } + if cur != nil { + where = append(where, `(s.updated_at?) OR (s.updated_at=? AND s.project_key=? AND s.task_id>?))`) + args = append(args, cur.Updated, cur.Updated, cur.Project, cur.Updated, cur.Project, cur.Task) + } + args = append(args, limit+1) + rows, err := c.db.QueryContext(ctx, `SELECT s.project_key,p.project_label,s.snapshot_json FROM task_snapshots s JOIN task_projects p ON p.project_key=s.project_key + WHERE `+strings.Join(where, ` AND `)+` ORDER BY s.updated_at DESC,s.project_key,s.task_id LIMIT ?`, args...) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var item Item + var raw []byte + if err := rows.Scan(&item.ProjectKey, &item.ProjectLabel, &raw); err != nil { + return out, err + } + if json.Unmarshal(raw, &item.Task) != nil { + continue + } + item.Task.ReconcileRuntime(time.Now()) + out.Items = append(out.Items, item) + } + if len(out.Items) > limit { + out.Items = out.Items[:limit] + last := out.Items[len(out.Items)-1] + out.NextCursor = encodeCursor(cursor{Revision: out.Revision, Updated: last.Task.UpdatedAt.UnixMilli(), Project: last.ProjectKey, Task: last.Task.TaskID}) + } + return out, rows.Err() +} + +func (c *Catalog) ListEventPage(ctx context.Context, projectKey, taskID string, after, limit int) (EventPage, error) { + out := EventPage{Items: []taskmonitor.TaskEvent{}, NextSequence: after} + project, ok, err := c.Project(ctx, projectKey) + if err != nil || !ok { + return out, err + } + if err := c.indexEvents(ctx, project.Root, taskID); err != nil { + out.Partial = true + return out, nil + } + if limit <= 0 { + limit = DefaultLimit + } + if limit > MaxLimit { + limit = MaxLimit + } + rows, err := c.db.QueryContext(ctx, `SELECT event_json FROM task_events WHERE project_key=? AND task_id=? AND sequence>? ORDER BY sequence LIMIT ?`, projectKey, taskID, after, limit) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var raw []byte + var event taskmonitor.TaskEvent + if err := rows.Scan(&raw); err != nil { + return out, err + } + if json.Unmarshal(raw, &event) == nil { + out.Items = append(out.Items, event) + out.NextSequence = event.Sequence + } + } + return out, rows.Err() +} + +func (c *Catalog) Project(ctx context.Context, key string) (Project, bool, error) { + var project Project + err := c.db.QueryRowContext(ctx, `SELECT project_key,project_root,project_label FROM task_projects WHERE project_key=?`, key).Scan(&project.Key, &project.Root, &project.Label) + if errors.Is(err, sql.ErrNoRows) { + return project, false, nil + } + return project, err == nil, err +} + +func (c *Catalog) projectByRoot(ctx context.Context, root string) (Project, bool, error) { + return c.Project(ctx, ProjectKey(root)) +} + +func bump(ctx context.Context, tx *sql.Tx) (uint64, error) { + if _, err := tx.ExecContext(ctx, `UPDATE task_state SET revision=revision+1 WHERE id=1`); err != nil { + return 0, err + } + var revision uint64 + err := tx.QueryRowContext(ctx, `SELECT revision FROM task_state WHERE id=1`).Scan(&revision) + return revision, err +} + +func encodeCursor(value cursor) string { + b, _ := json.Marshal(value) + return base64.RawURLEncoding.EncodeToString(b) +} + +func decodeCursor(value string) (*cursor, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + b, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("invalid task cursor: %w", err) + } + var out cursor + if json.Unmarshal(b, &out) != nil || out.Task == "" { + return nil, errors.New("invalid task cursor") + } + return &out, nil +} + +func (c *Catalog) refresh(ctx context.Context) { + var indexed, total, pending, failed int64 + _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_snapshots WHERE health='ok'`).Scan(&indexed) + _ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(total),0) FROM task_projects`).Scan(&total) + _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_projects WHERE state<>'ready'`).Scan(&pending) + _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_snapshots WHERE health='corrupt'`).Scan(&failed) + c.statusMu.Lock() + c.status.Revision, c.status.Indexed, c.status.Total, c.status.Pending, c.status.Failed = c.revision.Load(), indexed, total, pending, failed + c.statusMu.Unlock() +} + +func (c *Catalog) Status() Status { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return c.status +} + +func (c *Catalog) Close(ctx context.Context) error { + c.cancel() + done := make(chan struct{}) + go func() { c.wg.Wait(); close(done) }() + select { + case <-done: + return c.db.Close() + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/taskcatalog/catalog_test.go b/internal/taskcatalog/catalog_test.go new file mode 100644 index 0000000000..111595c29d --- /dev/null +++ b/internal/taskcatalog/catalog_test.go @@ -0,0 +1,97 @@ +package taskcatalog + +import ( + "context" + "path/filepath" + "testing" + "time" + + "reasonix/internal/taskmonitor" +) + +func snapshot(id, session string, version uint64, updated time.Time) taskmonitor.TaskSnapshot { + return taskmonitor.TaskSnapshot{SchemaVersion: 1, TaskID: id, SessionID: session, State: taskmonitor.TaskStateRunning, + RuntimeState: taskmonitor.RuntimeStateAlive, RuntimeLeaseUntil: updated.Add(time.Hour), Version: version, + CreatedAt: updated.Add(-time.Minute), UpdatedAt: updated} +} + +func TestObservedStoreIndexesSnapshotsAndEvents(t *testing.T) { + t.Parallel() + ctx := context.Background() + projectRoot := t.TempDir() + catalog, err := Open(ctx, filepath.Join(t.TempDir(), "tasks.sqlite")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + project, err := catalog.RegisterProject(ctx, projectRoot, "Demo") + if err != nil { + t.Fatal(err) + } + store := catalog.ObservedStore() + now := time.Now() + if err := store.SaveTask(ctx, projectRoot, snapshot("task-1", "session-1", 1, now)); err != nil { + t.Fatal(err) + } + if err := store.AppendAuditEvent(ctx, projectRoot, taskmonitor.TaskEvent{Timestamp: now, EventType: "state_change", TaskID: "task-1", SessionID: "session-1", State: taskmonitor.TaskStateRunning}); err != nil { + t.Fatal(err) + } + flushCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if err := catalog.Flush(flushCtx); err != nil { + t.Fatal(err) + } + page, err := catalog.ListPage(ctx, PageRequest{ProjectKeys: []string{project.Key}, SessionID: "session-1", Limit: 50}) + if err != nil || len(page.Items) != 1 || page.Items[0].Task.TaskID != "task-1" { + t.Fatalf("page=%#v err=%v", page, err) + } + events, err := catalog.ListEventPage(ctx, project.Key, "task-1", 0, 50) + if err != nil || len(events.Items) != 1 || events.NextSequence != 1 { + t.Fatalf("events=%#v err=%v", events, err) + } + if err := store.AppendAuditEvent(ctx, projectRoot, taskmonitor.TaskEvent{Timestamp: now.Add(time.Second), EventType: "state_change", + TaskID: "task-1", SessionID: "session-1", State: taskmonitor.TaskStateSucceeded}); err != nil { + t.Fatal(err) + } + if err := catalog.Flush(flushCtx); err != nil { + t.Fatal(err) + } + events, err = catalog.ListEventPage(ctx, project.Key, "task-1", 1, 50) + if err != nil || len(events.Items) != 1 || events.Items[0].Sequence != 2 { + t.Fatalf("incremental events=%#v err=%v", events, err) + } +} + +func TestPageCursorIsRevisionBound(t *testing.T) { + t.Parallel() + ctx := context.Background() + root := t.TempDir() + catalog, err := Open(ctx, filepath.Join(t.TempDir(), "tasks.sqlite")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + project, _ := catalog.RegisterProject(ctx, root, "Demo") + store := catalog.ObservedStore() + now := time.Now() + for i, id := range []string{"a", "b", "c"} { + if err := store.SaveTask(ctx, root, snapshot(id, "session", 1, now.Add(time.Duration(i)*time.Minute))); err != nil { + t.Fatal(err) + } + } + flushCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + _ = catalog.Flush(flushCtx) + first, err := catalog.ListPage(ctx, PageRequest{ProjectKeys: []string{project.Key}, Limit: 2}) + if err != nil || len(first.Items) != 2 || first.NextCursor == "" { + t.Fatalf("first=%#v err=%v", first, err) + } + if err := store.SaveTask(ctx, root, snapshot("d", "session", 1, now.Add(4*time.Minute))); err != nil { + t.Fatal(err) + } + _ = catalog.Flush(flushCtx) + stale, err := catalog.ListPage(ctx, PageRequest{ProjectKeys: []string{project.Key}, Cursor: first.NextCursor}) + if err != nil || !stale.StaleCursor || len(stale.Items) != 0 { + t.Fatalf("stale=%#v err=%v", stale, err) + } +} diff --git a/internal/taskcatalog/shared.go b/internal/taskcatalog/shared.go new file mode 100644 index 0000000000..8d3cf867ae --- /dev/null +++ b/internal/taskcatalog/shared.go @@ -0,0 +1,103 @@ +package taskcatalog + +import ( + "context" + "path/filepath" + "sync" + + "reasonix/internal/taskmonitor" +) + +type sharedManager struct { + once sync.Once + mu sync.RWMutex + catalog *Catalog + pending map[string]string +} + +var shared sharedManager + +func ensureShared() { + shared.once.Do(func() { + go func() { + catalog, err := Open(context.Background(), "") + if err != nil { + return + } + shared.mu.Lock() + shared.catalog = catalog + pending := shared.pending + shared.pending = nil + shared.mu.Unlock() + for root, label := range pending { + _, _ = catalog.RegisterProject(context.Background(), root, label) + } + }() + }) +} + +func Shared() *Catalog { + ensureShared() + shared.mu.RLock() + defer shared.mu.RUnlock() + return shared.catalog +} + +func RegisterSharedProject(root, label string) string { + ensureShared() + key := ProjectKey(root) + shared.mu.Lock() + if shared.catalog == nil { + if shared.pending == nil { + shared.pending = map[string]string{} + } + shared.pending[root] = label + shared.mu.Unlock() + return key + } + catalog := shared.catalog + shared.mu.Unlock() + _, _ = catalog.RegisterProject(context.Background(), root, label) + return key +} + +type sharedSink struct{} + +func (sharedSink) SnapshotChanged(projectRoot, taskID string) { + catalog := sharedCatalogForNotification(projectRoot) + if catalog != nil { + catalog.SnapshotChanged(projectRoot, taskID) + } +} + +func (sharedSink) EventsChanged(projectRoot, taskID string) { + catalog := sharedCatalogForNotification(projectRoot) + if catalog != nil { + catalog.EventsChanged(projectRoot, taskID) + } +} + +// sharedCatalogForNotification is deliberately SQLite-free. ProjectionSink is +// called after the authoritative task file lock is released, but task saves +// still must never wait for catalog I/O. A notification received while the +// catalog is opening is recovered by the pending project's initial reconcile. +func sharedCatalogForNotification(projectRoot string) *Catalog { + ensureShared() + shared.mu.Lock() + defer shared.mu.Unlock() + if shared.catalog == nil { + if shared.pending == nil { + shared.pending = map[string]string{} + } + shared.pending[projectRoot] = filepath.Base(projectRoot) + return nil + } + return shared.catalog +} + +// ObservedStore remains an authoritative FileStore; only its post-commit sink +// is shared with the disposable catalog. +func ObservedStore() taskmonitor.WriteStore { + ensureShared() + return taskmonitor.NewObservedFileStore(filepath.Join(".reasonix", "tasks"), sharedSink{}) +} diff --git a/internal/taskmonitor/event_tail.go b/internal/taskmonitor/event_tail.go new file mode 100644 index 0000000000..7f9cc9a3bf --- /dev/null +++ b/internal/taskmonitor/event_tail.go @@ -0,0 +1,75 @@ +package taskmonitor + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" +) + +type EventTail struct { + Items []TaskEvent + NextOffset int64 + Reset bool +} + +// ReadEventTail reads only complete JSONL lines after a catalog byte +// checkpoint. It preserves FileStore's identifier and symlink defenses. +func (s *FileStore) ReadEventTail(ctx context.Context, projectDir, taskID string, offset int64) (EventTail, error) { + out := EventTail{Items: []TaskEvent{}, NextOffset: offset} + if err := ctx.Err(); err != nil { + return out, err + } + id, err := safeID(taskID) + if err != nil { + return out, err + } + root, err := s.taskRoot(projectDir) + if err != nil { + return out, err + } + path := filepath.Join(root, id, "events.jsonl") + if err := rejectSymlinkChain(root, path); err != nil { + return out, err + } + f, err := os.Open(path) + if os.IsNotExist(err) { + return out, nil + } + if err != nil { + return out, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return out, err + } + if offset < 0 || offset > info.Size() { + offset, out.NextOffset, out.Reset = 0, 0, true + } + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return out, err + } + reader := bufio.NewReader(f) + for { + line, readErr := reader.ReadBytes('\n') + if len(line) > 0 && line[len(line)-1] == '\n' { + out.NextOffset += int64(len(line)) + line = line[:len(line)-1] + var event TaskEvent + if json.Unmarshal(line, &event) == nil { + out.Items = append(out.Items, event) + } + } + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil { + return out, readErr + } + } + return out, nil +} diff --git a/internal/taskmonitor/event_tail_test.go b/internal/taskmonitor/event_tail_test.go new file mode 100644 index 0000000000..03baaabedf --- /dev/null +++ b/internal/taskmonitor/event_tail_test.go @@ -0,0 +1,49 @@ +package taskmonitor + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestReadEventTailKeepsIncompleteLineForRetry(t *testing.T) { + t.Parallel() + root := t.TempDir() + store := NewFileStore(filepath.Join(".reasonix", "tasks")) + now := time.Now() + first := TaskEvent{Timestamp: now, EventType: "state_change", TaskID: "task", State: TaskStateRunning} + if err := store.AppendAuditEvent(context.Background(), root, first); err != nil { + t.Fatal(err) + } + tail, err := store.ReadEventTail(context.Background(), root, "task", 0) + if err != nil || len(tail.Items) != 1 { + t.Fatalf("first tail=%#v err=%v", tail, err) + } + checkpoint := tail.NextOffset + second := TaskEvent{Sequence: 2, Timestamp: now.Add(time.Second), EventType: "state_change", TaskID: "task", State: TaskStateSucceeded} + line, _ := json.Marshal(second) + path := filepath.Join(root, ".reasonix", "tasks", "task", "events.jsonl") + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + _, _ = f.Write(line) + _ = f.Close() + tail, err = store.ReadEventTail(context.Background(), root, "task", checkpoint) + if err != nil || len(tail.Items) != 0 || tail.NextOffset != checkpoint { + t.Fatalf("incomplete tail=%#v err=%v", tail, err) + } + f, err = os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + _, _ = f.Write([]byte{'\n'}) + _ = f.Close() + tail, err = store.ReadEventTail(context.Background(), root, "task", checkpoint) + if err != nil || len(tail.Items) != 1 || tail.Items[0].Sequence != 2 { + t.Fatalf("completed tail=%#v err=%v", tail, err) + } +} diff --git a/internal/taskmonitor/jsonstore.go b/internal/taskmonitor/jsonstore.go index debfcbfc93..020b25916f 100644 --- a/internal/taskmonitor/jsonstore.go +++ b/internal/taskmonitor/jsonstore.go @@ -21,6 +21,7 @@ import ( // is added in TM-04. type FileStore struct { baseDir string // projectDir → task data root (e.g. ".reasonix/tasks") + sink ProjectionSink } // NewFileStore returns a FileStore rooted at baseDir. baseDir is typically @@ -29,6 +30,10 @@ func NewFileStore(baseDir string) *FileStore { return &FileStore{baseDir: baseDir} } +func NewObservedFileStore(baseDir string, sink ProjectionSink) *FileStore { + return &FileStore{baseDir: baseDir, sink: sink} +} + // safeID validates a user-supplied identifier for use as a filesystem path // component. It rejects empty strings, ".", "..", and values containing a // path separator. Used for both taskID and idempotency keys. @@ -332,6 +337,12 @@ func (s *FileStore) readEvents(taskDir string) ([]TaskEvent, error) { // SaveTask implements WriteStore. It atomically writes the snapshot, // failing if a concurrent write has changed the version. func (s *FileStore) SaveTask(ctx context.Context, projectDir string, snap TaskSnapshot) (retErr error) { + committed := false + defer func() { + if committed && s.sink != nil { + s.sink.SnapshotChanged(projectDir, snap.TaskID) + } + }() if err := ctx.Err(); err != nil { return err } @@ -413,6 +424,7 @@ func (s *FileStore) SaveTask(ctx context.Context, projectDir string, snap TaskSn return fmt.Errorf("save task: %w", err) } _ = os.Chmod(target, 0o600) + committed = true return nil } @@ -420,6 +432,12 @@ func (s *FileStore) SaveTask(ctx context.Context, projectDir string, snap TaskSn // AppendAuditEvent implements WriteStore. It atomically assigns the next // monotonic sequence number and appends the event to the JSONL file. func (s *FileStore) AppendAuditEvent(ctx context.Context, projectDir string, ev TaskEvent) (retErr error) { + committed := false + defer func() { + if committed && s.sink != nil { + s.sink.EventsChanged(projectDir, ev.TaskID) + } + }() if err := ctx.Err(); err != nil { return err } @@ -506,6 +524,7 @@ func (s *FileStore) AppendAuditEvent(ctx context.Context, projectDir string, ev if _, err := f.WriteString(string(data) + "\n"); err != nil { return err } + committed = true return nil } diff --git a/internal/taskmonitor/model.go b/internal/taskmonitor/model.go index 6240f0a5a0..5837697c4a 100644 --- a/internal/taskmonitor/model.go +++ b/internal/taskmonitor/model.go @@ -82,6 +82,10 @@ func reconcileRuntime(snap *TaskSnapshot, now time.Time) { } } +// ReconcileRuntime applies the read-time lease view without mutating the +// authoritative snapshot file. +func (ts *TaskSnapshot) ReconcileRuntime(now time.Time) { reconcileRuntime(ts, now) } + // ValidTaskStates is the set of well-known states. var ValidTaskStates = map[TaskState]bool{ TaskStateQueued: true, diff --git a/internal/taskmonitor/projection_sink_test.go b/internal/taskmonitor/projection_sink_test.go new file mode 100644 index 0000000000..58a2e9eab7 --- /dev/null +++ b/internal/taskmonitor/projection_sink_test.go @@ -0,0 +1,56 @@ +package taskmonitor + +import ( + "context" + "path/filepath" + "sync" + "testing" + "time" +) + +type reentrantProjectionSink struct { + store *FileStore + mu sync.Mutex + ran bool + done chan error +} + +func (s *reentrantProjectionSink) SnapshotChanged(root, taskID string) { + s.mu.Lock() + if s.ran { + s.mu.Unlock() + return + } + s.ran = true + s.mu.Unlock() + task, err := s.store.GetTask(context.Background(), root, taskID) + if err == nil && task != nil { + task.Version++ + task.UpdatedAt = task.UpdatedAt.Add(time.Second) + err = s.store.SaveTask(context.Background(), root, *task) + } + s.done <- err +} + +func (*reentrantProjectionSink) EventsChanged(string, string) {} + +func TestProjectionSinkRunsAfterTaskLockRelease(t *testing.T) { + t.Parallel() + root := t.TempDir() + sink := &reentrantProjectionSink{done: make(chan error, 1)} + sink.store = NewObservedFileStore(filepath.Join(".reasonix", "tasks"), sink) + now := time.Now() + err := sink.store.SaveTask(context.Background(), root, TaskSnapshot{SchemaVersion: 1, TaskID: "task", State: TaskStateQueued, + Version: 1, CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + select { + case err := <-sink.done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("projection sink was invoked while the task lock was still held") + } +} diff --git a/internal/taskmonitor/store.go b/internal/taskmonitor/store.go index c76c432eff..bcda5d132d 100644 --- a/internal/taskmonitor/store.go +++ b/internal/taskmonitor/store.go @@ -18,6 +18,13 @@ type Store interface { ListEvents(ctx context.Context, projectDir string, taskID string, afterSequence int) ([]TaskEvent, error) } +// ProjectionSink receives post-commit hints. Implementations must enqueue and +// return immediately; FileStore remains the only authority for task control. +type ProjectionSink interface { + SnapshotChanged(projectRoot, taskID string) + EventsChanged(projectRoot, taskID string) +} + // IdempotencyRecord captures the binding between an idempotency key and the // operation it was used for. type IdempotencyRecord struct { From 3739f12a7d32f6cde65ee2f30ef6ccb5c998a348 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:06:02 +0800 Subject: [PATCH 3/6] fix(tasks): complete catalog lifecycle contracts Problem: the isolated Task Catalog branch lacked shell completion metadata and only flushed its shared projection during desktop shutdown, allowing late reconcile goroutines to outlive the shutdown boundary. Root cause: the task implementation was originally layered on the integrated History branch, so domain-specific lifecycle and documentation details were lost when it was split into an independently reviewable branch. Fix: register task reindex completions, document the task cache and recovery commands, gate reconcile scheduling during close, wait for scheduled workers, close the shared catalog within the 250ms desktop drain, and account for the measured 1 KiB frontend bundle increase. Verification: focused Desktop tests, frontend production build and bundle budgets, repository lint, core package tests, and race tests for projectiondb, taskcatalog, taskmonitor, and control passed. --- .../frontend/scripts/check-bundle-budget.mjs | 2 +- desktop/session_catalog.go | 5 +- docs/CLI.md | 10 ++ docs/CLI.zh-CN.md | 10 ++ docs/CONFIG_PATHS.md | 3 + docs/CONFIG_PATHS.zh-CN.md | 8 ++ internal/cli/catalogs.go | 7 +- internal/cli/catalogs_tasks.go | 8 +- internal/cli/shell_completion.go | 11 +- internal/taskcatalog/catalog.go | 12 +- internal/taskcatalog/shared.go | 33 +++++ tools/repolint/baseline.json | 115 +++++++++--------- 12 files changed, 153 insertions(+), 71 deletions(-) diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index d9e3392ce4..4f8f0386a6 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -83,5 +83,5 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // small always-available contracts, while the catalog project shell adds // cursor, revision, and progress state. Keep their combined allowance tightly // ratcheted while the gzip gate stays flat. -assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_209 * 1024); +assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_210 * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); diff --git a/desktop/session_catalog.go b/desktop/session_catalog.go index 74e9484756..0e103ec7e8 100644 --- a/desktop/session_catalog.go +++ b/desktop/session_catalog.go @@ -61,10 +61,7 @@ type ProjectTreeChangedV2 struct { } func flushDesktopTaskCatalog(ctx context.Context) error { - if catalog := taskcatalog.Shared(); catalog != nil { - return catalog.Flush(ctx) - } - return nil + return taskcatalog.ShutdownShared(ctx) } func sessionCatalogStatus(status sessioncatalog.Status) SessionCatalogStatus { diff --git a/docs/CLI.md b/docs/CLI.md index b11da6920b..f63a5d5361 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -468,6 +468,16 @@ Without `--dir`, reindex includes global sessions and all projects saved by the desktop app. See [Session Catalog and Desktop Startup](./SESSION_CATALOG.md) for failure, migration, and data-safety guarantees. +Inspect or rebuild the disposable task projection independently: + +```sh +reasonix doctor catalogs [--json] +reasonix catalogs reindex tasks [--project PATH ...] [--json] +``` + +See [Task Catalog](./TASK_CATALOG.md) for the authoritative FileStore boundary, +cross-project routing, and rebuild behavior. + ### Memory diagnostics and recovery Bare `/memory` shows all active project/global facts without hiding same-name diff --git a/docs/CLI.zh-CN.md b/docs/CLI.zh-CN.md index b484b4d3f7..e21ccc4a12 100644 --- a/docs/CLI.zh-CN.md +++ b/docs/CLI.zh-CN.md @@ -406,6 +406,16 @@ reasonix sessions reindex --dir /path/to/sessions --dir /another/path 不传 `--dir` 时,reindex 会覆盖全局会话和桌面保存的全部项目。迁移、失败降级和数据安全 契约见 [Session Catalog and Desktop Startup](./SESSION_CATALOG.md)。 +可以独立检查或重建可丢弃的 Task 投影: + +```sh +reasonix doctor catalogs [--json] +reasonix catalogs reindex tasks [--project PATH ...] [--json] +``` + +权威 FileStore 边界、跨项目路由和重建行为见 +[Task Catalog](./TASK_CATALOG.zh-CN.md)。 + ### 记忆诊断与恢复 直接运行 `/memory` 会显示全部 project/global active facts,不会隐藏跨 scope 的同名条目。 diff --git a/docs/CONFIG_PATHS.md b/docs/CONFIG_PATHS.md index f6258533da..ce8dc95b55 100644 --- a/docs/CONFIG_PATHS.md +++ b/docs/CONFIG_PATHS.md @@ -41,6 +41,7 @@ non-destructively when `/.env` is missing them. | Archives | `/archive/` | | Memory | `/memory/` and `/projects/` | | Disposable session catalog | `/session-catalog/v1.sqlite` | +| Disposable task catalog | `/task-catalog/v1.sqlite` | `` defaults to ``. It only differs when `REASONIX_STATE_HOME` is set. @@ -48,6 +49,8 @@ non-destructively when `/.env` is missing them. The session catalog is a rebuildable query projection, not user data. Session JSONL, event logs, metadata sidecars, and `desktop-projects.json` remain authoritative. See [Session Catalog and Desktop Startup](./SESSION_CATALOG.md). +Task snapshots and event logs likewise remain authoritative; the rebuildable +cross-project projection is documented in [Task Catalog](./TASK_CATALOG.md). The global user config is named `config.toml`. Project-local config files keep the name `reasonix.toml`. If someone says "global reasonix.toml", they usually diff --git a/docs/CONFIG_PATHS.zh-CN.md b/docs/CONFIG_PATHS.zh-CN.md index 5e48952438..9ea54dc2e8 100644 --- a/docs/CONFIG_PATHS.zh-CN.md +++ b/docs/CONFIG_PATHS.zh-CN.md @@ -33,10 +33,18 @@ Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳 | 会话 | `/sessions/` | | 归档 | `/archive/` | | 记忆 | `/memory/` 与 `/projects/` | +| 可丢弃的会话 Catalog | `/session-catalog/v1.sqlite` | +| 可丢弃的 Task Catalog | `/task-catalog/v1.sqlite` | `` 默认等于 ``;只有设置 `REASONIX_STATE_HOME` 时才会不同。 +会话 Catalog 是可重建的查询投影,不是用户数据;JSONL、event log、 +metadata sidecar 和 `desktop-projects.json` 仍是权威数据。详见 +[Session Catalog and Desktop Startup](./SESSION_CATALOG.zh-CN.md)。Task snapshot 和 +event log 也仍是权威数据;可重建的跨项目投影见 +[Task Catalog](./TASK_CATALOG.zh-CN.md)。 + 全局用户配置文件名是 `config.toml`。项目本地配置文件仍叫 `reasonix.toml`。 如果有人说“全局 reasonix.toml”,通常指的是 `/config.toml`。 diff --git a/internal/cli/catalogs.go b/internal/cli/catalogs.go index b57278382c..87752900c7 100644 --- a/internal/cli/catalogs.go +++ b/internal/cli/catalogs.go @@ -18,9 +18,10 @@ type catalogInspection struct { } type catalogCommand struct { - name string - path func() string - reindex func([]string) int + name string + path func() string + reindex func([]string) int + completionFlags []cliCompletionFlag } var catalogCommands []catalogCommand diff --git a/internal/cli/catalogs_tasks.go b/internal/cli/catalogs_tasks.go index d573664497..be555d3691 100644 --- a/internal/cli/catalogs_tasks.go +++ b/internal/cli/catalogs_tasks.go @@ -12,7 +12,13 @@ import ( ) func init() { - registerCatalogCommand(catalogCommand{name: "tasks", path: taskcatalog.DefaultPath, reindex: reindexTaskCatalog}) + registerCatalogCommand(catalogCommand{ + name: "tasks", path: taskcatalog.DefaultPath, reindex: reindexTaskCatalog, + completionFlags: []cliCompletionFlag{ + completionFlag("--json", cliCompletionNoValue), + completionFlag("--project", cliCompletionPathValue), + }, + }) } func reindexTaskCatalog(args []string) int { diff --git a/internal/cli/shell_completion.go b/internal/cli/shell_completion.go index 129c65d45b..6610af7f1d 100644 --- a/internal/cli/shell_completion.go +++ b/internal/cli/shell_completion.go @@ -46,6 +46,15 @@ func completionSpecWithAliases(name string, aliases []string, flags []cliComplet return cliCompletionSpec{name: name, aliases: aliases, flags: flags, subcommands: subcommands} } +func catalogCompletionSpec(help cliCompletionFlag) cliCompletionSpec { + reindex := make([]cliCompletionSpec, 0, len(catalogCommands)) + for _, command := range catalogCommands { + flags := append(slices.Clone(command.completionFlags), help) + reindex = append(reindex, completionSpec(command.name, flags)) + } + return completionSpec("catalogs", []cliCompletionFlag{help}, completionSpec("reindex", []cliCompletionFlag{help}, reindex...)) +} + func cliCompletionRootSpec() cliCompletionSpec { profile := completionFlag("--profile", cliCompletionStaticValue, "economy", "balanced", "delivery") model := completionFlag("--model", cliCompletionModelValue) @@ -89,7 +98,6 @@ func cliCompletionRootSpec() cliCompletionSpec { completionFlag("--ablate", cliCompletionStaticValue, "none", "all", "evidence", "planner", "subagent", "retrieval", "compaction"), help, } - root := cliCompletionSpec{name: "reasonix", flags: append([]cliCompletionFlag{ model, completionFlag("--max-steps", cliCompletionStaticValue), @@ -207,6 +215,7 @@ func cliCompletionRootSpec() cliCompletionSpec { completionFlag("--json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help, }), ), + catalogCompletionSpec(help), completionSpec("report", []cliCompletionFlag{help}, completionSpec("list", []cliCompletionFlag{help}), completionSpec("show", []cliCompletionFlag{help}), diff --git a/internal/taskcatalog/catalog.go b/internal/taskcatalog/catalog.go index 61481d0741..5c4357a457 100644 --- a/internal/taskcatalog/catalog.go +++ b/internal/taskcatalog/catalog.go @@ -101,6 +101,7 @@ type Catalog struct { dirtyWake chan struct{} dirtyProjects sync.Map wg sync.WaitGroup + closing atomic.Bool revision atomic.Uint64 statusMu sync.RWMutex status Status @@ -180,6 +181,9 @@ func (c *Catalog) EventsChanged(projectRoot, taskID string) { } func (c *Catalog) enqueue(req request) { + if c.closing.Load() { + return + } select { case c.queue <- req: default: @@ -298,17 +302,19 @@ func (c *Catalog) RequestReconcileProject(ctx context.Context, root, label strin func (c *Catalog) scheduleReconcile(project Project) { c.reconcileMu.Lock() - if c.reconciling[project.Key] { + if c.closing.Load() || c.reconciling[project.Key] { c.reconcileMu.Unlock() return } c.reconciling[project.Key] = true + c.wg.Add(1) c.reconcileMu.Unlock() go func() { defer func() { c.reconcileMu.Lock() delete(c.reconciling, project.Key) c.reconcileMu.Unlock() + c.wg.Done() }() _ = c.ReconcileProject(c.ctx, project) }() @@ -654,7 +660,11 @@ func (c *Catalog) Status() Status { } func (c *Catalog) Close(ctx context.Context) error { + c.closing.Store(true) c.cancel() + // Synchronize with scheduleReconcile so Wait cannot race a late Add. + c.reconcileMu.Lock() + c.reconcileMu.Unlock() done := make(chan struct{}) go func() { c.wg.Wait(); close(done) }() select { diff --git a/internal/taskcatalog/shared.go b/internal/taskcatalog/shared.go index 8d3cf867ae..b3e7349789 100644 --- a/internal/taskcatalog/shared.go +++ b/internal/taskcatalog/shared.go @@ -13,6 +13,7 @@ type sharedManager struct { mu sync.RWMutex catalog *Catalog pending map[string]string + closing bool } var shared sharedManager @@ -25,6 +26,11 @@ func ensureShared() { return } shared.mu.Lock() + if shared.closing { + shared.mu.Unlock() + _ = catalog.Close(context.Background()) + return + } shared.catalog = catalog pending := shared.pending shared.pending = nil @@ -43,10 +49,34 @@ func Shared() *Catalog { return shared.catalog } +// ShutdownShared drains accepted notifications and cancels every shared task +// projection worker. It is only used during process shutdown; authoritative +// task snapshots and event logs have already committed before notifications. +func ShutdownShared(ctx context.Context) error { + shared.mu.Lock() + shared.closing = true + catalog := shared.catalog + shared.catalog = nil + shared.mu.Unlock() + if catalog == nil { + return nil + } + flushErr := catalog.Flush(ctx) + closeErr := catalog.Close(ctx) + if flushErr != nil { + return flushErr + } + return closeErr +} + func RegisterSharedProject(root, label string) string { ensureShared() key := ProjectKey(root) shared.mu.Lock() + if shared.closing { + shared.mu.Unlock() + return key + } if shared.catalog == nil { if shared.pending == nil { shared.pending = map[string]string{} @@ -85,6 +115,9 @@ func sharedCatalogForNotification(projectRoot string) *Catalog { ensureShared() shared.mu.Lock() defer shared.mu.Unlock() + if shared.closing { + return nil + } if shared.catalog == nil { if shared.pending == nil { shared.pending = map[string]string{} diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index 70d451189a..ccab47a49f 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -2,14 +2,14 @@ "limits": { "banner": 15, "commented-code": 0, - "complexity": 2112, - "essay": 4090, - "file-size": 111363, - "function-size": 9254, + "complexity": 2077, + "essay": 4082, + "file-size": 109589, + "function-size": 8945, "layering": 1, "marker": 0, "narrative": 64, - "test-file-size": 70202 + "test-file-size": 69713 }, "files": { "cmd/e2ebench/main.go": { @@ -28,15 +28,15 @@ "desktop/app.go": { "complexity": 63, "essay": 96, - "file-size": 11489, - "function-size": 381 + "file-size": 11487, + "function-size": 383 }, "desktop/app_autosave_test.go": { "essay": 1 }, "desktop/app_test.go": { "essay": 2, - "test-file-size": 10227 + "test-file-size": 10217 }, "desktop/bot_bridge.go": { "essay": 11 @@ -84,7 +84,7 @@ "essay": 2 }, "desktop/frontend/src/App.tsx": { - "file-size": 4733 + "file-size": 4712 }, "desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": { "test-file-size": 19 @@ -123,22 +123,22 @@ "file-size": 2658 }, "desktop/frontend/src/components/Composer.tsx": { - "file-size": 4042 + "file-size": 4003 }, "desktop/frontend/src/components/MemoryPanel.tsx": { "file-size": 1088 }, "desktop/frontend/src/components/Message.tsx": { - "file-size": 185 + "file-size": 91 }, "desktop/frontend/src/components/ProjectTree.tsx": { - "file-size": 1587 + "file-size": 1445 }, "desktop/frontend/src/components/RichComposerInput.tsx": { "file-size": 141 }, "desktop/frontend/src/components/SettingsPanel.tsx": { - "file-size": 6960 + "file-size": 6768 }, "desktop/frontend/src/components/StatusBar.tsx": { "file-size": 20 @@ -150,7 +150,7 @@ "file-size": 158 }, "desktop/frontend/src/components/Transcript.tsx": { - "file-size": 443 + "file-size": 409 }, "desktop/frontend/src/components/UsageStatsPanel.tsx": { "file-size": 276 @@ -162,7 +162,7 @@ "file-size": 415 }, "desktop/frontend/src/lib/bridge.ts": { - "file-size": 4751 + "file-size": 4752 }, "desktop/frontend/src/lib/crash.ts": { "file-size": 225 @@ -171,7 +171,7 @@ "file-size": 307 }, "desktop/frontend/src/lib/types.ts": { - "file-size": 1457 + "file-size": 1453 }, "desktop/frontend/src/lib/useController.ts": { "file-size": 3929 @@ -273,7 +273,7 @@ }, "desktop/sessions.go": { "essay": 5, - "file-size": 459 + "file-size": 422 }, "desktop/sessions_lease_guard_test.go": { "essay": 3 @@ -284,8 +284,7 @@ "desktop/settings_app.go": { "complexity": 22, "essay": 26, - "file-size": 2868, - "function-size": 25 + "file-size": 2797 }, "desktop/settings_app_test.go": { "test-file-size": 1857 @@ -307,10 +306,10 @@ "essay": 1 }, "desktop/tabs.go": { - "complexity": 74, - "essay": 133, - "file-size": 8716, - "function-size": 630 + "complexity": 52, + "essay": 131, + "file-size": 8177, + "function-size": 409 }, "desktop/tabs_order_test.go": { "test-file-size": 395 @@ -320,7 +319,7 @@ }, "desktop/tabs_topic_test.go": { "essay": 1, - "test-file-size": 3409 + "test-file-size": 3227 }, "desktop/temphelper_test.go": { "essay": 4 @@ -439,7 +438,7 @@ "internal/agent/agent.go": { "complexity": 61, "essay": 107, - "file-size": 2807, + "file-size": 2711, "function-size": 125 }, "internal/agent/ask.go": { @@ -470,12 +469,12 @@ "essay": 3 }, "internal/agent/coordinator.go": { - "essay": 17, - "file-size": 267 + "essay": 15, + "file-size": 13 }, "internal/agent/coordinator_test.go": { "essay": 3, - "test-file-size": 1239 + "test-file-size": 1025 }, "internal/agent/delivery_hardening_test.go": { "essay": 5 @@ -493,8 +492,8 @@ "test-file-size": 501 }, "internal/agent/execute_batch.go": { - "complexity": 45, - "function-size": 208 + "complexity": 44, + "function-size": 207 }, "internal/agent/execute_one.go": { "complexity": 13, @@ -574,15 +573,14 @@ "essay": 1 }, "internal/agent/run_loop.go": { - "complexity": 8, + "complexity": 1, "essay": 32, - "file-size": 258, - "function-size": 45 + "file-size": 174 }, "internal/agent/save.go": { "complexity": 49, - "essay": 113, - "file-size": 1544, + "essay": 112, + "file-size": 1536, "function-size": 124 }, "internal/agent/save_test.go": { @@ -642,8 +640,8 @@ }, "internal/agent/task.go": { "complexity": 25, - "essay": 55, - "file-size": 1295, + "essay": 53, + "file-size": 1277, "function-size": 114 }, "internal/agent/task_test.go": { @@ -674,8 +672,8 @@ "internal/boot/boot.go": { "complexity": 295, "essay": 102, - "file-size": 2226, - "function-size": 1854, + "file-size": 2229, + "function-size": 1855, "narrative": 3 }, "internal/boot/boot_test.go": { @@ -809,22 +807,22 @@ }, "internal/cli/chat_tui.go": { "complexity": 326, - "essay": 110, - "file-size": 4676, - "function-size": 1286 + "essay": 109, + "file-size": 4669, + "function-size": 1280 }, "internal/cli/chat_tui_paste.go": { "essay": 9 }, "internal/cli/chat_tui_test.go": { "essay": 8, - "test-file-size": 3604 + "test-file-size": 3596 }, "internal/cli/cli.go": { "complexity": 75, "essay": 60, - "file-size": 1951, - "function-size": 506 + "file-size": 1950, + "function-size": 505 }, "internal/cli/cli_test.go": { "essay": 9, @@ -928,7 +926,7 @@ "file-size": 126 }, "internal/cli/shell_completion.go": { - "function-size": 117 + "function-size": 112 }, "internal/cli/skill_hooks.go": { "essay": 1 @@ -997,7 +995,7 @@ }, "internal/config/edit.go": { "essay": 15, - "file-size": 1681 + "file-size": 1673 }, "internal/config/edit_test.go": { "test-file-size": 2418 @@ -1071,13 +1069,13 @@ "internal/control/controller.go": { "complexity": 10, "essay": 162, - "file-size": 5452, - "function-size": 75, + "file-size": 5428, + "function-size": 79, "narrative": 4 }, "internal/control/controller_test.go": { "essay": 10, - "test-file-size": 4671 + "test-file-size": 4610 }, "internal/control/errmsg.go": { "essay": 2 @@ -1094,13 +1092,10 @@ "narrative": 2 }, "internal/control/goal.go": { - "complexity": 7, + "complexity": 3, "essay": 11, - "file-size": 313, - "function-size": 11 - }, - "internal/control/goal_runtime_test.go": { - "test-file-size": 14 + "file-size": 274, + "function-size": 1 }, "internal/control/goal_test.go": { "test-file-size": 243 @@ -1167,9 +1162,9 @@ "essay": 1 }, "internal/control/turn_orchestrator.go": { - "complexity": 3, + "complexity": 2, "essay": 13, - "function-size": 62 + "function-size": 60 }, "internal/control/turn_orchestrator_test.go": { "essay": 1, @@ -1213,7 +1208,7 @@ "internal/evidence/evidence.go": { "complexity": 6, "essay": 38, - "file-size": 2083 + "file-size": 2030 }, "internal/evidence/evidence_test.go": { "essay": 1, @@ -1514,7 +1509,7 @@ "internal/pluginpkg/pluginpkg.go": { "complexity": 1, "essay": 2, - "file-size": 426 + "file-size": 407 }, "internal/pluginpkg/pluginpkg_test.go": { "test-file-size": 67 From 35839b4671e7d49014cdc3c26e9fa8ed42858c68 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:18:33 +0800 Subject: [PATCH 4/6] chore(tasks): refresh lint baseline after main sync Problem: the latest main-v2 transcript-selection work intentionally enlarged existing frontend files after the Task branch had captured its repository lint baseline. Fix: regenerate the carry-forward baseline on the merged Task branch without adding new domain violations. Verification: repolint, focused Desktop tests, frontend production build, and task/control package tests pass. --- tools/repolint/baseline.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index ccab47a49f..bcfc4daf15 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -2,10 +2,10 @@ "limits": { "banner": 15, "commented-code": 0, - "complexity": 2077, + "complexity": 2074, "essay": 4082, - "file-size": 109589, - "function-size": 8945, + "file-size": 109610, + "function-size": 8937, "layering": 1, "marker": 0, "narrative": 64, @@ -150,7 +150,7 @@ "file-size": 158 }, "desktop/frontend/src/components/Transcript.tsx": { - "file-size": 409 + "file-size": 438 }, "desktop/frontend/src/components/UsageStatsPanel.tsx": { "file-size": 276 @@ -819,10 +819,10 @@ "test-file-size": 3596 }, "internal/cli/cli.go": { - "complexity": 75, + "complexity": 72, "essay": 60, - "file-size": 1950, - "function-size": 505 + "file-size": 1942, + "function-size": 497 }, "internal/cli/cli_test.go": { "essay": 9, From 20d50817178d159f78e905dae922fc7fe5174d47 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:28:28 +0800 Subject: [PATCH 5/6] fix(storage): remove superseded catalog helpers Problem: after Session Catalog adopted projectiondb, the old filesystem probes and migration entrypoint remained unused, while CI also flagged two modern Go style issues. Root cause: the common projection extraction preserved obsolete package-local helpers to minimize the first refactor diff. Fix: remove the dead remote-filesystem and migration helpers, use integer range in the benchmark, and iterate managed plugin path segments with strings.SplitSeq. Verification: sessioncatalog, pluginpkg, and projectiondb tests pass; golangci-lint reports zero issues. --- internal/pluginpkg/installed_compat.go | 2 +- internal/sessioncatalog/catalog_test.go | 2 +- internal/sessioncatalog/remote_fs_darwin.go | 28 -------------------- internal/sessioncatalog/remote_fs_linux.go | 27 ------------------- internal/sessioncatalog/remote_fs_other.go | 5 ---- internal/sessioncatalog/remote_fs_windows.go | 26 ------------------ internal/sessioncatalog/schema.go | 4 --- 7 files changed, 2 insertions(+), 92 deletions(-) delete mode 100644 internal/sessioncatalog/remote_fs_darwin.go delete mode 100644 internal/sessioncatalog/remote_fs_linux.go delete mode 100644 internal/sessioncatalog/remote_fs_other.go delete mode 100644 internal/sessioncatalog/remote_fs_windows.go diff --git a/internal/pluginpkg/installed_compat.go b/internal/pluginpkg/installed_compat.go index b47996edbc..a7140f9f44 100644 --- a/internal/pluginpkg/installed_compat.go +++ b/internal/pluginpkg/installed_compat.go @@ -83,7 +83,7 @@ func managedPluginRoot(reasonixHome string, installed InstalledPlugin, root stri return false } current := managed - for _, part := range strings.Split(rel, string(filepath.Separator)) { + for part := range strings.SplitSeq(rel, string(filepath.Separator)) { current = filepath.Join(current, part) info, err := os.Lstat(current) if err != nil || info.Mode()&os.ModeSymlink != 0 { diff --git a/internal/sessioncatalog/catalog_test.go b/internal/sessioncatalog/catalog_test.go index dd9dc27964..e2baed0821 100644 --- a/internal/sessioncatalog/catalog_test.go +++ b/internal/sessioncatalog/catalog_test.go @@ -443,7 +443,7 @@ func BenchmarkListTopicsWarmCatalog10K(b *testing.B) { } } b.ResetTimer() - for i := 0; i < b.N; i++ { + for range b.N { page, err := catalog.ListTopics(ctx, TopicPageRequest{ Scope: "project", WorkspaceRoot: "/workspace", Limit: 50, }) diff --git a/internal/sessioncatalog/remote_fs_darwin.go b/internal/sessioncatalog/remote_fs_darwin.go deleted file mode 100644 index 50d90805ba..0000000000 --- a/internal/sessioncatalog/remote_fs_darwin.go +++ /dev/null @@ -1,28 +0,0 @@ -//go:build darwin - -package sessioncatalog - -import ( - "strings" - "syscall" -) - -func catalogFilesystemRemote(path string) bool { - var stat syscall.Statfs_t - if err := syscall.Statfs(path, &stat); err != nil { - return false - } - name := make([]byte, 0, len(stat.Fstypename)) - for _, char := range stat.Fstypename { - if char == 0 { - break - } - name = append(name, byte(char)) - } - switch strings.ToLower(string(name)) { - case "nfs", "smbfs", "webdav", "afpfs", "cifs": - return true - default: - return false - } -} diff --git a/internal/sessioncatalog/remote_fs_linux.go b/internal/sessioncatalog/remote_fs_linux.go deleted file mode 100644 index f4eb51c22e..0000000000 --- a/internal/sessioncatalog/remote_fs_linux.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build linux - -package sessioncatalog - -import "syscall" - -const ( - nfsSuperMagic = 0x6969 - smbSuperMagic = 0x517B - cifsSuperMagic = 0xFF534D42 - nfsdSuperMagic = 0x6E667364 - cephSuperMagic = 0x00C36400 - lustreSuperMagic = 0x0BD00BD0 -) - -func catalogFilesystemRemote(path string) bool { - var stat syscall.Statfs_t - if err := syscall.Statfs(path, &stat); err != nil { - return false - } - switch uint64(stat.Type) { - case nfsSuperMagic, smbSuperMagic, cifsSuperMagic, nfsdSuperMagic, cephSuperMagic, lustreSuperMagic: - return true - default: - return false - } -} diff --git a/internal/sessioncatalog/remote_fs_other.go b/internal/sessioncatalog/remote_fs_other.go deleted file mode 100644 index 316cf362fd..0000000000 --- a/internal/sessioncatalog/remote_fs_other.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !darwin && !linux && !windows - -package sessioncatalog - -func catalogFilesystemRemote(string) bool { return false } diff --git a/internal/sessioncatalog/remote_fs_windows.go b/internal/sessioncatalog/remote_fs_windows.go deleted file mode 100644 index aa1781f5dc..0000000000 --- a/internal/sessioncatalog/remote_fs_windows.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build windows - -package sessioncatalog - -import ( - "path/filepath" - "strings" - - "golang.org/x/sys/windows" -) - -func catalogFilesystemRemote(path string) bool { - volume := filepath.VolumeName(filepath.Clean(path)) - if volume == "" { - return false - } - root := volume - if !strings.HasSuffix(root, `\`) { - root += `\` - } - rootPtr, err := windows.UTF16PtrFromString(root) - if err != nil { - return false - } - return windows.GetDriveType(rootPtr) == windows.DRIVE_REMOTE -} diff --git a/internal/sessioncatalog/schema.go b/internal/sessioncatalog/schema.go index 27b4351782..a327bff939 100644 --- a/internal/sessioncatalog/schema.go +++ b/internal/sessioncatalog/schema.go @@ -115,7 +115,3 @@ func schemaMigrations() []projectiondb.Migration { {Version: 3, Apply: execMigration(migrationV3)}, } } - -func migrateSchema(ctx context.Context, db *sql.DB) error { - return projectiondb.ApplyMigrations(ctx, db, schemaMigrations(), nil) -} From f80f273fb79e399cd9e88cba16e58dd6704e859a Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:28:36 +0800 Subject: [PATCH 6/6] fix(tasks): make reconcile shutdown lint-safe Problem: the shutdown barrier used an intentionally empty mutex critical section, which staticcheck rejects. Fix: guard a reconcileDone flag with the same mutex used for scheduling so Close prevents late WaitGroup additions without an empty critical section. Verification: taskcatalog tests pass and golangci-lint reports zero issues. --- internal/taskcatalog/catalog.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/taskcatalog/catalog.go b/internal/taskcatalog/catalog.go index 5c4357a457..0252ecf95a 100644 --- a/internal/taskcatalog/catalog.go +++ b/internal/taskcatalog/catalog.go @@ -108,6 +108,7 @@ type Catalog struct { reconcileMu sync.Mutex reconciling map[string]bool registered map[string]bool + reconcileDone bool } func DefaultPath() string { return filepath.Join(config.CacheDir(), "task-catalog", "v1.sqlite") } @@ -302,7 +303,7 @@ func (c *Catalog) RequestReconcileProject(ctx context.Context, root, label strin func (c *Catalog) scheduleReconcile(project Project) { c.reconcileMu.Lock() - if c.closing.Load() || c.reconciling[project.Key] { + if c.reconcileDone || c.reconciling[project.Key] { c.reconcileMu.Unlock() return } @@ -662,8 +663,8 @@ func (c *Catalog) Status() Status { func (c *Catalog) Close(ctx context.Context) error { c.closing.Store(true) c.cancel() - // Synchronize with scheduleReconcile so Wait cannot race a late Add. c.reconcileMu.Lock() + c.reconcileDone = true c.reconcileMu.Unlock() done := make(chan struct{}) go func() { c.wg.Wait(); close(done) }()