From bf19574d3aed0832295a4a27307576ca0396b0ba Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:16:26 +0800 Subject: [PATCH 1/3] feat(desktop): add resilient session catalog Problem: Desktop startup and project-tree queries could synchronously migrate and decode legacy session JSONL, blocking navigation, creation, and shutdown. Root cause: Session discovery, metadata repair, controller admission, and plugin startup shared UI-critical paths and broad lifecycle locks. Fix: - add a disposable SQLite session catalog with incremental reconciliation, repair checkpoints, corruption recovery, and memory fallback - expose revisioned cursor pagination and migrate the desktop project tree to lazy loading - move controller construction outside lifecycle admission and isolate incompatible plugin startup - add session doctor and reindex commands while preserving authoritative JSONL and metadata - retain CGO-free builds with platform-specific fallbacks Verification: - go test ./... - cd desktop && go test ./... - go test -race ./internal/sessioncatalog ./internal/agent - cd desktop && go test -race . - go vet ./... in root and desktop - cd desktop/frontend && pnpm test:all && pnpm build - CGO_ENABLED=0 cross-builds for supported macOS, Windows, and Linux targets --- desktop/app.go | 77 +- desktop/app_test.go | 32 +- .../frontend/scripts/check-bundle-budget.mjs | 6 +- desktop/frontend/src/App.tsx | 35 +- .../__tests__/project-tree-runtime.test.ts | 48 + desktop/frontend/src/components/Composer.tsx | 23 +- .../frontend/src/components/HistoryPanel.tsx | 11 +- .../frontend/src/components/ProjectTree.tsx | 143 ++- desktop/frontend/src/lib/bridge.ts | 56 ++ desktop/frontend/src/lib/types.ts | 51 + desktop/frontend/src/locales/en.ts | 4 + desktop/frontend/src/locales/zh-TW.ts | 4 + desktop/frontend/src/locales/zh.ts | 4 + desktop/frontend/src/styles.css | 7 + desktop/go.mod | 9 +- desktop/go.sum | 44 +- desktop/hang_watchdog_darwin.go | 2 +- desktop/hang_watchdog_darwin_nocgo.go | 9 + desktop/session_catalog.go | 902 ++++++++++++++++++ desktop/session_catalog_app_test.go | 55 ++ desktop/settings_app.go | 13 +- desktop/startup_health_test.go | 43 + desktop/system_quit_darwin.go | 2 +- desktop/system_quit_stub.go | 2 +- desktop/tabs.go | 631 ++---------- desktop/tabs_topic_test.go | 376 ++------ desktop/task_monitor_test.go | 2 +- desktop/topic_archive.go | 1 + desktop/tray.go | 42 +- desktop/tray_common.go | 41 + desktop/tray_loop_external.go | 2 +- desktop/tray_nocgo.go | 18 + desktop/webkit_compat_linux.go | 2 + desktop/webkit_compat_other.go | 2 +- docs/CLI.md | 16 + docs/CLI.zh-CN.md | 14 + docs/CONFIG_PATHS.md | 5 + docs/SESSION_CATALOG.md | 101 ++ docs/SESSION_CATALOG.zh-CN.md | 87 ++ go.mod | 9 + go.sum | 44 + internal/agent/listsessions_sidecar_test.go | 92 +- internal/agent/save.go | 53 +- internal/agent/session_listing_helpers.go | 3 +- internal/boot/extension_sidecar_test.go | 32 + internal/cli/cli.go | 3 + internal/cli/doctor.go | 41 + internal/cli/doctor_test.go | 75 ++ internal/cli/plugin.go | 3 + internal/cli/sessions_catalog.go | 109 +++ internal/cli/shell_completion.go | 6 + internal/doctor/session_redact.go | 48 +- internal/pluginpkg/compat_legacy_test.go | 59 ++ internal/pluginpkg/manifest_v2.go | 10 +- internal/pluginpkg/pluginpkg.go | 71 +- internal/sessioncatalog/catalog.go | 763 +++++++++++++++ internal/sessioncatalog/catalog_test.go | 488 ++++++++++ internal/sessioncatalog/metadata.go | 82 ++ internal/sessioncatalog/reconcile.go | 616 ++++++++++++ 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 | 149 +++ internal/sessioncatalog/types.go | 163 ++++ 65 files changed, 4869 insertions(+), 1058 deletions(-) create mode 100644 desktop/hang_watchdog_darwin_nocgo.go create mode 100644 desktop/session_catalog.go create mode 100644 desktop/session_catalog_app_test.go create mode 100644 desktop/tray_common.go create mode 100644 desktop/tray_nocgo.go create mode 100644 docs/SESSION_CATALOG.md create mode 100644 docs/SESSION_CATALOG.zh-CN.md create mode 100644 internal/cli/sessions_catalog.go create mode 100644 internal/sessioncatalog/catalog.go create mode 100644 internal/sessioncatalog/catalog_test.go create mode 100644 internal/sessioncatalog/metadata.go create mode 100644 internal/sessioncatalog/reconcile.go create mode 100644 internal/sessioncatalog/remote_fs_darwin.go create mode 100644 internal/sessioncatalog/remote_fs_linux.go create mode 100644 internal/sessioncatalog/remote_fs_other.go create mode 100644 internal/sessioncatalog/remote_fs_windows.go create mode 100644 internal/sessioncatalog/schema.go create mode 100644 internal/sessioncatalog/types.go diff --git a/desktop/app.go b/desktop/app.go index ba61e8b02b..0a11882d97 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -53,6 +53,7 @@ import ( "reasonix/internal/pluginpkg" "reasonix/internal/provider" "reasonix/internal/repair" + "reasonix/internal/sessioncatalog" "reasonix/internal/sessiontemp" "reasonix/internal/skill" "reasonix/internal/stats" @@ -128,6 +129,16 @@ type App struct { ctx context.Context workspaceHub *workspaceChangeHub + // sessionCatalog is a disposable, asynchronously opened projection of + // authoritative session sidecars. Project-shell APIs must tolerate nil here: + // opening, migration, repair, and corruption recovery never gate the UI. + sessionCatalog atomic.Pointer[sessioncatalog.Catalog] + catalogLifecycleMu sync.Mutex + catalogCancel context.CancelFunc + catalogDone chan struct{} + catalogRebuilding atomic.Bool + shuttingDown atomic.Bool + // taskCtrl is the process-wide task-monitor control service (lazy; see // taskControl). One instance serializes control operations in-process. taskCtrl *taskmonitor.ControlService @@ -199,11 +210,12 @@ type App struct { // -> Host/Registry. runtimeRebuildMu sync.Mutex // runtimeAdmissionMu is the runtime lifecycle barrier. Foreground turn-start - // tokens and controller builds hold the read side; runtime teardown and MCP - // lifecycle mutations hold the write side so their captured controller/Host - // cannot be replaced, closed, or handed a late turn in flight. Writers already - // hold runtimeRebuildMu, making them mutually exclusive. Read holders must never - // acquire runtimeRebuildMu, or a queued writer would deadlock the pair. + // tokens and the short publication phase of asynchronous controller builds + // hold the read side; runtime teardown and MCP lifecycle mutations hold the + // write side so their captured controller/Host cannot be replaced, closed, or + // handed a late turn in flight. Writers already hold runtimeRebuildMu, making + // them mutually exclusive. Read holders must never acquire runtimeRebuildMu, + // or a queued writer would deadlock the pair. runtimeAdmissionMu sync.RWMutex // runtimeMutationBeforeLockHook is test-only. Set it before starting concurrent // calls and never mutate it afterward. @@ -564,6 +576,7 @@ func (a *App) Platform() string { // off the initialization in a background goroutine so the webview loads immediately. func (a *App) startup(ctx context.Context) { a.ctx = ctx + a.shuttingDown.Store(false) a.startWindowsWebView2StartupFallback(ctx) if a.remoteWindowTicket != "" { // Remote web window child: no local tabs, tray, heartbeat, providers, @@ -597,6 +610,7 @@ func (a *App) startup(ctx context.Context) { a.tabsRestored = make(chan struct{}) a.mu.Unlock() go a.restoreOrBuildTabs() + a.startSessionCatalog(false) a.goSafe("refreshBotRuntime", a.refreshBotRuntime) a.goSafe("sendStartupPing", a.sendStartupPing) a.goSafe("flushMetrics", a.flushMetrics) @@ -961,6 +975,12 @@ func (a *App) shutdown(context.Context) { // Remote web window child: nothing to snapshot or stop locally. return } + // Freeze publication first, then cancel work that is deliberately outside + // the lifecycle barrier. A history scan, catalog repair, or plugin handshake + // must never make normal quit wait for its I/O. + a.shuttingDown.Store(true) + a.cancelAllTabBuilds() + a.stopSessionCatalog(250 * time.Millisecond) if a.workspaceHub != nil { a.workspaceHub.close() } @@ -1201,6 +1221,14 @@ func (a *App) beginTabTurn(tabID string, reclaim bool, submissionID ...string) ( if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil { return nil, nil, a.workspaceNotReadyErr(tab) } + // A stale workspace binding may require a full controller/plugin rebuild. + // Complete that cancellable work before entering the lifecycle barrier so + // shutdown and runtime mutations never wait behind filesystem or handshake + // I/O. The admission re-check below protects the short publication-to-turn + // boundary. + if err := a.ensureTabControllerWorkspace(tab); err != nil { + return nil, nil, err + } // Runtime work-admission barrier: held (shared) from here until the turn is // observably running and the returned admission token releases it, so an MCP // MCP authorization or plugin uninstall holding the write side either waits out @@ -1224,10 +1252,6 @@ func (a *App) beginTabTurn(tabID string, reclaim bool, submissionID ...string) ( abort() return nil, nil, err } - if err := a.ensureTabControllerWorkspaceAdmissionHeld(tab); err != nil { - abort() - return nil, nil, err - } ctrl = a.controllerForTab(tab) if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil { abort() @@ -1639,16 +1663,6 @@ func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string { } func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error { - a.runtimeAdmissionMu.RLock() - defer a.runtimeAdmissionMu.RUnlock() - return a.ensureTabControllerWorkspaceAdmissionHeld(tab) -} - -// ensureTabControllerWorkspaceAdmissionHeld repairs a stale controller binding -// while the caller holds either side of runtimeAdmissionMu. The repair may build -// a controller synchronously, so it must not recursively acquire the read side: -// sync.RWMutex blocks new readers once a writer is queued. -func (a *App) ensureTabControllerWorkspaceAdmissionHeld(tab *WorkspaceTab) error { if tab == nil { return nil } @@ -1724,7 +1738,7 @@ func (a *App) ensureTabControllerWorkspaceAdmissionHeld(tab *WorkspaceTab) error a.releaseSharedHost(hostKey) } - a.buildTabControllerAdmissionHeld(tab) + a.buildTabController(tab) if tab.Ctrl == nil { if tab.StartupErr != "" { return fmt.Errorf("workspace failed to restart with corrected root: %s", tab.StartupErr) @@ -2272,7 +2286,7 @@ func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) { _ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli()) path := a.currentSessionPathFor(tab) a.persistTabSessionPath(tab, path) - a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(path)) + a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(path)) } func messagesHaveConversationContent(messages []provider.Message) bool { @@ -2989,7 +3003,7 @@ func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) { meta := a.tabMeta(tab, activateFork) a.mu.Unlock() - a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(newPath)) + a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(newPath)) a.startTabControllerBuild(tab) return meta, nil } @@ -3032,6 +3046,7 @@ type SessionMeta struct { Preview string `json:"preview"` // first user message Title string `json:"title,omitempty"` // user-chosen name, when set (overrides preview) Turns int `json:"turns"` + TurnsState string `json:"turnsState"` CreatedAt int64 `json:"createdAt"` // unix milliseconds LastActivityAt int64 `json:"lastActivityAt"` // unix milliseconds ModTime int64 `json:"modTime"` // compatibility alias for lastActivityAt @@ -3222,11 +3237,16 @@ func (a *App) sessionDirForPath(path string) (string, string, error) { } func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta { + turnsState := "unknown" + if s.CountsKnown { + turnsState = "valid" + } return SessionMeta{ Path: s.Path, Preview: s.Preview, Title: title, Turns: s.Turns, + TurnsState: turnsState, CreatedAt: s.CreatedAt.UnixMilli(), LastActivityAt: s.LastActivityAt.UnixMilli(), ModTime: s.LastActivityAt.UnixMilli(), @@ -3413,6 +3433,7 @@ func (a *App) deleteSession(path string, requireRedundantRecovery bool) error { return err } } + a.removeSessionCatalogPath(sessionPath, "session_deleted") a.emitProjectTreeChangedForSessionDirs(dir) a.invalidatePromptHistoryCache() return nil @@ -3814,6 +3835,7 @@ func (a *App) restoreSession(path string) error { if err := restoreSessionTopicIndex(dir, target); err != nil { return err } + a.requestSessionCatalogPath("", "", target) a.emitProjectTreeChangedForSessionDirs(dir) a.invalidatePromptHistoryCache() return nil @@ -3901,6 +3923,7 @@ func (a *App) RenameSession(path, title string) error { if err := setSessionTitle(dir, sessionPath, title); err != nil { return err } + a.requestSessionCatalogPath("", "", sessionPath) a.invalidatePromptHistoryCache() a.emitProjectTreeChangedForSessionDirs(dir) return nil @@ -4081,11 +4104,10 @@ func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string a.runtimeRebuildMu.Lock() defer a.runtimeRebuildMu.Unlock() - // Fence an in-flight startup before waiting for the admission barrier. The - // startup build holds the barrier's read side for its whole build; cancelling - // its generation first lets it retire instead of making this writer wait on - // a build that still believes it can publish. App.mu is released before the - // barrier acquisition, so no inverted lock nesting is introduced. + // Fence an in-flight startup before waiting for the admission barrier. Startup + // work now runs outside that barrier and will discard itself at its short + // publication check once this generation is superseded. App.mu is released + // before barrier acquisition, so no inverted lock nesting is introduced. a.mu.Lock() if tab.removed || a.tabs[tab.ID] != tab { a.mu.Unlock() @@ -5324,7 +5346,6 @@ func (a *App) RemoveWorkspace(dir string) error { clearWorkspace() } } - projectSessionCache.forgetDirs(desktopSessionDir(dir)) a.emitProjectTreeMetadataChanged() return nil } diff --git a/desktop/app_test.go b/desktop/app_test.go index b822d63239..248ce80be1 100644 --- a/desktop/app_test.go +++ b/desktop/app_test.go @@ -6941,7 +6941,8 @@ func TestDesktopSessionAPIsUseControllerSessionDir(t *testing.T) { defer app.activeCtrl().Close() sessions := app.ListSessions() - if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != "workspace A" { + if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].TurnsState != "unknown" || + !strings.Contains(sessions[0].Preview, "being indexed") { t.Fatalf("ListSessions should read the active controller session dir only, got %+v", sessions) } if err := app.RenameSession(pathA, "A title"); err != nil { @@ -8577,7 +8578,7 @@ func TestBridgeDriveReleasesRuntimeAdmissionWhenTakeoverWasReclaimed(t *testing. fixture.app.runtimeAdmissionMu.Unlock() } -func TestBeginTabTurnWorkspaceRepairDoesNotRecursivelyLockAdmission(t *testing.T) { +func TestBeginTabTurnWorkspaceRepairStaysOutsideLifecycleAdmission(t *testing.T) { fixture := newStaleWorkspaceBindingFixture(t, "admission_writer") fixture.tab.reconcileMu.Lock() @@ -8589,16 +8590,6 @@ func TestBeginTabTurnWorkspaceRepairDoesNotRecursivelyLockAdmission(t *testing.T } turnDone <- err }() - deadline := time.Now().Add(5 * time.Second) - for fixture.app.runtimeAdmissionMu.TryLock() { - fixture.app.runtimeAdmissionMu.Unlock() - if time.Now().After(deadline) { - fixture.tab.reconcileMu.Unlock() - t.Fatal("beginTabTurn never acquired the admission read lock") - } - time.Sleep(time.Millisecond) - } - writerRebuildLocked := make(chan struct{}) writerAdmissionLocked := make(chan struct{}) writerDone := make(chan struct{}) @@ -8612,9 +8603,13 @@ func TestBeginTabTurnWorkspaceRepairDoesNotRecursivelyLockAdmission(t *testing.T close(writerDone) }() <-writerRebuildLocked - for fixture.app.runtimeAdmissionMu.TryRLock() { - fixture.app.runtimeAdmissionMu.RUnlock() - time.Sleep(time.Millisecond) + select { + case <-writerAdmissionLocked: + // The repair is still blocked on reconcileMu; acquiring the lifecycle + // writer here proves no slow repair/build I/O owns the read side. + case <-time.After(5 * time.Second): + fixture.tab.reconcileMu.Unlock() + t.Fatal("workspace repair held runtimeAdmissionMu while waiting") } fixture.tab.reconcileMu.Unlock() @@ -8624,12 +8619,7 @@ func TestBeginTabTurnWorkspaceRepairDoesNotRecursivelyLockAdmission(t *testing.T t.Fatalf("beginTabTurn after workspace repair: %v", err) } case <-time.After(10 * time.Second): - t.Fatal("workspace repair recursively waited on runtimeAdmissionMu with a writer pending") - } - select { - case <-writerAdmissionLocked: - case <-time.After(5 * time.Second): - t.Fatal("lifecycle writer never acquired runtimeAdmissionMu after repaired turn admission") + t.Fatal("workspace repair did not complete after lifecycle writer released") } select { case <-writerDone: diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index a68867aed3..0ad0e4e8fb 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -80,7 +80,7 @@ for (const path of localeChunks) { const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] .reduce((total, path) => total + statSync(path).size, 0); // Native Web Animations and frame-batched scrolling avoid an eager animation -// runtime. Preserve the resulting startup headroom instead of letting the shell -// drift back to the previous 2.27 MiB / 295 KiB-gzip edge. -assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_200 * 1024); +// runtime. The 4 KiB catalog allowance covers the project shell's cursor, +// revision, and progress state; keep the much tighter gzip gate unchanged. +assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_204 * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 75b0d72592..8516a66107 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -105,7 +105,6 @@ import { type DesktopStartupSettingsView, type Mode, modeHasPlan, - type ProjectNode, type RewindResultView, type RemoteHostView, type SessionMeta, @@ -884,30 +883,6 @@ function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenS ); } -function activeTopicTurnsFromTree(tree: ProjectNode[], tab?: TabMeta): number | undefined { - if (!tab?.topicId) return undefined; - const targetScope = tab.scope === "global" ? "global" : "project"; - const walk = (nodes: ProjectNode[]): number | undefined => { - for (const node of nodes) { - if (!node) continue; - if (node.kind === "topic" || node.kind === "global_topic") { - const scope = node.kind === "global_topic" ? "global" : "project"; - if ( - scope === targetScope && - node.topicId === tab.topicId && - (scope === "global" || node.root === tab.workspaceRoot) - ) { - return node.turns; - } - } - const found = walk(asArray(node.children)); - if (found !== undefined) return found; - } - return undefined; - }; - return walk(tree); -} - function normalizeDesktopPlatform(value: string): DesktopPlatform { if (value === "darwin" || value === "windows") return value; return "linux"; @@ -1720,9 +1695,13 @@ export default function App() { cancelled = true; }; } - void app.ListProjectTree() - .then((tree) => { - if (!cancelled) setActiveTopicTurns(activeTopicTurnsFromTree(asArray(tree), activeTab)); + void app.GetTopicSummary({ + scope: activeTab.scope === "global" ? "global" : "project", + workspaceRoot: activeTab.scope === "global" ? "" : activeTab.workspaceRoot, + topicId: activeTab.topicId, + }) + .then((topic) => { + if (!cancelled) setActiveTopicTurns(topic.turns); }) .catch(() => { if (!cancelled) setActiveTopicTurns(undefined); diff --git a/desktop/frontend/src/__tests__/project-tree-runtime.test.ts b/desktop/frontend/src/__tests__/project-tree-runtime.test.ts index 11244b7f33..233902f62f 100644 --- a/desktop/frontend/src/__tests__/project-tree-runtime.test.ts +++ b/desktop/frontend/src/__tests__/project-tree-runtime.test.ts @@ -2,6 +2,9 @@ import { projectTreeFolderDisclosure, + mergeProjectTopicPage, + projectTreeEventAffectsFolder, + projectTreeRevisionIsFresh, defaultExpandedProjectTreeKeys, activeSessionAncestorKeys, projectTreeTopicOpenRequest, @@ -148,6 +151,19 @@ eq( "topic with no turns and no timestamps renders previous-time fallback meta", ); +eq( + projectTreeTopicMetaLine({ + key: "global_topic_indexing", + kind: "global_topic", + label: "Legacy topic", + topicId: "indexing", + turns: 0, + turnsState: "unknown", + }, testT), + "history.indexing", + "unknown legacy turn counts are never presented as zero turns", +); + eq( projectTreeTopicMetaLine({ key: "global_topic_recent", @@ -608,5 +624,37 @@ eq( "expanded classic empty folders report the open state for the placeholder", ); +eq( + [projectTreeRevisionIsFresh(12, 11), projectTreeRevisionIsFresh(12, 12), projectTreeRevisionIsFresh(12, 13)], + [false, true, true], + "project tree ignores stale snapshots and pages while accepting the current revision", +); + +eq( + mergeProjectTopicPage( + [ + { key: "topic-a", kind: "topic", label: "A", topicId: "a" }, + { key: "topic-b", kind: "topic", label: "Old B", topicId: "b" }, + ], + [ + { key: "topic-b", kind: "topic", label: "New B", topicId: "b" }, + { key: "topic-c", kind: "topic", label: "C", topicId: "c" }, + ], + true, + ).map((node) => `${node.key}:${node.label}`), + ["topic-a:A", "topic-b:New B", "topic-c:C"], + "overlapping keyset pages replace duplicates without changing stable order", +); + +eq( + [ + projectTreeEventAffectsFolder({ key: "global", kind: "global_folder", label: "Global" }, [""]), + projectTreeEventAffectsFolder({ key: "p", kind: "project", label: "P", root: "/repo" }, ["/other"]), + projectTreeEventAffectsFolder({ key: "p", kind: "project", label: "P", root: "/repo" }, []), + ], + [true, false, true], + "revision events refresh only affected expanded roots, with an empty roots list as broadcast", +); + console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 119fd6c9e6..3d23cfbb22 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -3172,6 +3172,7 @@ export function Composer({ title: session.title || session.topicTitle || session.preview || "Untitled", preview: session.preview, turns: session.turns, + turnsState: session.turnsState, createdAt: session.createdAt, lastActivityAt: session.lastActivityAt, }, @@ -4121,18 +4122,20 @@ export function Composer({ // PR-C2: hover preview uses only the SessionMeta fields we // already have on hand — no extra PreviewSession call, no // backend round-trip, no read of the full transcript. - const turns = typeof session.turns === "number"; + const turnsKnown = session.turnsState !== "unknown" && typeof session.turns === "number"; + const turnsPending = session.turnsState === "unknown"; const ts = session.lastActivityAt || session.modTime || session.createdAt; const preview = truncatePreview(session.preview); const pathText = session.workspaceRoot || session.path; const tooltipLabel = - turns || ts || preview || pathText ? ( + turnsKnown || turnsPending || ts || preview || pathText ? (
{pastChatTitle(session)}
{preview &&
{preview}
} - {(turns || ts) && ( + {(turnsKnown || turnsPending || ts) && (
- {turns && {t("composer.sessionTurns", { n: session.turns })}} + {turnsPending && {t("history.indexing")}} + {turnsKnown && {t("composer.sessionTurns", { n: session.turns })}} {ts && · {fmtSessionTime(ts)}}
)} @@ -4152,7 +4155,11 @@ export function Composer({ {pastChatTitle(session)} - {turns ? ` (${t("composer.sessionTurns", { n: session.turns })})` : ""} + {turnsPending + ? ` (${t("history.indexing")})` + : turnsKnown + ? ` (${t("composer.sessionTurns", { n: session.turns })})` + : ""} @@ -4317,7 +4324,11 @@ export function Composer({ {ref.title} - {typeof ref.turns === "number" ? ` (${t("composer.sessionTurns", { n: ref.turns })})` : ""} + {ref.turnsState === "unknown" + ? ` (${t("history.indexing")})` + : typeof ref.turns === "number" + ? ` (${t("composer.sessionTurns", { n: ref.turns })})` + : ""} diff --git a/desktop/frontend/src/components/HistoryPanel.tsx b/desktop/frontend/src/components/HistoryPanel.tsx index f825a905f7..0cdfd499a3 100644 --- a/desktop/frontend/src/components/HistoryPanel.tsx +++ b/desktop/frontend/src/components/HistoryPanel.tsx @@ -542,7 +542,11 @@ export function HistoryPanel({ {s.recovered && {tr("recovery.badge")}} {sessionLocation(s, tr) && {sessionLocation(s, tr)}} - {tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })} + + {s.turnsState === "unknown" + ? tr("history.indexing") + : tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })} + · {timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s))} {!isTrash && running && ( @@ -688,7 +692,10 @@ function sessionMetaLine(s: SessionMeta, tr: ReturnType, isTrash = const time = timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s)); const suffix = isTrash && s.deletedAt ? ` · ${tr("history.deleted")}` : ""; const prefix = isChannelSession(s) ? `${tr("history.channelReadOnly")} · ` : ""; - return `${prefix}${tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })} · ${time}${suffix}`; + const turns = s.turnsState === "unknown" + ? tr("history.indexing") + : tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns }); + return `${prefix}${turns} · ${time}${suffix}`; } function previewMessagesToItems(messages: HistoryMessage[]): Item[] { diff --git a/desktop/frontend/src/components/ProjectTree.tsx b/desktop/frontend/src/components/ProjectTree.tsx index a466ddc1ab..9b82e2d219 100644 --- a/desktop/frontend/src/components/ProjectTree.tsx +++ b/desktop/frontend/src/components/ProjectTree.tsx @@ -8,8 +8,8 @@ import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as React import { Archive, ArrowDown, Pencil, Plus, Folder, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, Check, ListCollapse, ListRestart, MessageSquare, Clock, Pin, MoreHorizontal, Minimize2, Maximize2, GitBranch } from "lucide-react"; import { asArray } from "../lib/array"; import { useToast } from "../lib/toast"; -import { app } from "../lib/bridge"; -import type { ProjectNode, ProjectTopicStatus } from "../lib/types"; +import { app, onProjectTreeChangedV2 } from "../lib/bridge"; +import type { ProjectNode, ProjectTopicStatus, SessionCatalogStatus } from "../lib/types"; import { topicActivityTime } from "../lib/session"; import { getLocale, useT, type DictKey, type Translator } from "../lib/i18n"; import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors"; @@ -64,6 +64,32 @@ function isTopicNode(node: ProjectNode): boolean { return node.kind === "topic" || node.kind === "global_topic"; } +export function projectTreeRevisionIsFresh(currentRevision: number, incomingRevision: number): boolean { + return incomingRevision >= currentRevision; +} + +export function mergeProjectTopicPage(current: ProjectNode[], incoming: ProjectNode[], append: boolean): ProjectNode[] { + if (!append) return [...incoming]; + const next = [...current]; + const positions = new Map(next.map((node, index) => [node.key, index])); + for (const node of incoming) { + const index = positions.get(node.key); + if (index === undefined) { + positions.set(node.key, next.length); + next.push(node); + } else { + next[index] = node; + } + } + return next; +} + +export function projectTreeEventAffectsFolder(project: ProjectNode, roots: string[]): boolean { + if (roots.length === 0) return true; + const root = project.kind === "global_folder" ? "" : project.root ?? ""; + return roots.includes(root); +} + export type ProjectTreeTopicOpenRequest = { scope: "global" | "project"; workspaceRoot: string; @@ -134,7 +160,8 @@ function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceR export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string { const parts: string[] = []; const turns = node.turns ?? 0; - if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns })); + if (node.turnsState === "unknown") parts.push(t("history.indexing")); + else if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns })); const activityAt = node.lastActivityAt || node.createdAt || 0; if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact)); if (parts.length === 0) parts.push(t("projectTree.previously")); @@ -617,6 +644,20 @@ export function ProjectTree({ const compactTopics = variant === "workbench"; const creationTopics = variant === "creation"; const [tree, setTree] = useState([]); + const treeRef = useRef([]); + const latestRevisionRef = useRef(0); + const [catalogStatus, setCatalogStatus] = useState({ + state: "opening", revision: 0, indexed: 0, total: 0, repairPending: 0, + }); + const [topicPageState, setTopicPageState] = useState>({}); + const topicPageStateRef = useRef(topicPageState); + const updateTopicPageState = useCallback((key: string, next: { nextCursor?: string; loading: boolean }) => { + setTopicPageState((current) => { + const updated = { ...current, [key]: next }; + topicPageStateRef.current = updated; + return updated; + }); + }, []); const [expanded, setExpanded] = useState>(new Set()); const [manuallyCollapsed, setManuallyCollapsed] = useState>(new Set()); const [creatingProject, setCreatingProject] = useState(null); @@ -695,15 +736,61 @@ export function ProjectTree({ }); }, []); - // Keyed by the data, not the selection: a switch cannot change the tree. + const loadProjectTopics = useCallback(async (project: ProjectNode, append = false) => { + if (project.kind !== "project" && project.kind !== "global_folder") return; + const key = project.key; + const pageState = topicPageStateRef.current[key]; + if (pageState?.loading) return; + const cursor = append ? pageState?.nextCursor ?? "" : ""; + if (append && !cursor) return; + updateTopicPageState(key, { ...pageState, loading: true }); + try { + const page = await app.ListProjectTopics({ + scope: project.kind === "global_folder" ? "global" : "project", + workspaceRoot: project.kind === "global_folder" ? "" : project.root ?? "", + cursor, + limit: timeFilter === "10" ? 10 : timeFilter === "20" ? 20 : 50, + query: query.trim(), + timeFilter: timeFilter === "10" || timeFilter === "20" || timeFilter === "all" ? "" : timeFilter, + }); + if (!projectTreeRevisionIsFresh(latestRevisionRef.current, page.revision)) { + updateTopicPageState(key, { ...topicPageStateRef.current[key], loading: false }); + return; + } + latestRevisionRef.current = Math.max(latestRevisionRef.current, page.revision); + const items = asArray(page.items); + setTree((current) => current.map((node) => { + if (node.key !== key) return node; + return { ...node, children: mergeProjectTopicPage(asArray(node.children), items, append) }; + })); + updateTopicPageState(key, { nextCursor: page.nextCursor, loading: false }); + } catch { + updateTopicPageState(key, { ...topicPageStateRef.current[key], loading: false }); + } + }, [query, timeFilter, updateTopicPageState]); + + // Snapshot is intentionally shells-only. Preserve already loaded pages by + // project key so a metadata refresh does not collapse or blank the sidebar. const refresh = useCallback(async () => { try { - setTree(asArray(await app.ListProjectTree())); + const snapshot = await app.GetProjectTreeSnapshot(); + if (!projectTreeRevisionIsFresh(latestRevisionRef.current, snapshot.revision ?? 0)) return; + const projects = asArray(snapshot.projects); + latestRevisionRef.current = Math.max(latestRevisionRef.current, snapshot.revision ?? 0); + setCatalogStatus(snapshot.catalog); + setTree((current) => projects.map((project) => { + const previous = current.find((node) => node.key === project.key); + return { ...project, children: asArray(previous?.children) }; + })); } catch { /* bridge unavailable */ } }, []); + useEffect(() => { + treeRef.current = tree; + }, [tree]); + useEffect(() => { manuallyCollapsedRef.current = manuallyCollapsed; }, [manuallyCollapsed]); @@ -719,6 +806,26 @@ export function ProjectTree({ void refresh(); }, [refresh, refreshSignal]); + useEffect(() => onProjectTreeChangedV2((event) => { + if (event.revision <= latestRevisionRef.current) return; + latestRevisionRef.current = event.revision; + void app.GetSessionCatalogStatus().then(setCatalogStatus).catch(() => {}); + const affected = asArray(event.roots); + for (const project of treeRef.current) { + const key = projectNodeKey(project, 0); + if (!expanded.has(key)) continue; + if (projectTreeEventAffectsFolder(project, affected)) void loadProjectTopics(project); + } + }), [expanded, loadProjectTopics]); + + useEffect(() => { + const filtering = query.trim() !== "" || timeFilter !== "all"; + for (const project of treeRef.current) { + const key = projectNodeKey(project, 0); + if (filtering || expanded.has(key)) void loadProjectTopics(project); + } + }, [expanded, loadProjectTopics, query, timeFilter]); + // Following the active topic is a view concern over the tree already held. useEffect(() => { const collapsed = manuallyCollapsedRef.current; @@ -852,7 +959,7 @@ export function ProjectTree({ items[next]?.focus(); }; - const toggleExpand = (key: string) => { + const toggleExpand = (key: string, project?: ProjectNode) => { const willCollapse = expanded.has(key); setExpanded((prev) => { const next = new Set(prev); @@ -866,6 +973,7 @@ export function ProjectTree({ else next.delete(key); return next; }); + if (!willCollapse && project) void loadProjectTopics(project); }; const folderKeys = useMemo(() => collapsibleFolderKeys(tree), [tree]); @@ -1291,7 +1399,9 @@ export function ProjectTree({ const children = asArray(node.children); const isExpanded = query.trim() ? true : expanded.has(key); const hasChildren = children.length > 0; - const folderDisclosure = projectTreeFolderDisclosure(hasChildren, isExpanded, classicTopics); + // Snapshot rows are shells with no children until the first page is loaded, + // so every project folder must remain expandable while indexing. + const folderDisclosure = projectTreeFolderDisclosure(hasChildren, isExpanded, true); if (isTopicNode(node) || isRuntimeSessionNode(node)) { const isSessionNode = isRuntimeSessionNode(node); @@ -1784,6 +1894,7 @@ export function ProjectTree({ ? classicTopicWindow(children, folderShowAll) : { visible: children, hiddenCount: 0 }; const windowToggleVisible = classicTruncationActive && (hiddenCount > 0 || (folderShowAll && children.length > CLASSIC_TOPIC_PREVIEW_LIMIT)); + const backendPage = topicPageState[key]; const renderFolderChildren = () => { if (!hasChildren) { if (!classicTopics) return null; @@ -1811,6 +1922,17 @@ export function ProjectTree({ {hiddenCount > 0 ? t("projectTree.showMoreTopics", { n: hiddenCount }) : t("projectTree.showFewerTopics")} )} + {backendPage?.nextCursor && ( + + )}
); @@ -1861,7 +1983,7 @@ export function ProjectTree({ className="project-tree__folder-main" style={{ paddingLeft: 8 + depth * 16 }} onClick={() => { - if (folderDisclosure.canExpand) toggleExpand(key); + if (folderDisclosure.canExpand) toggleExpand(key, node); }} onKeyDown={(event) => { if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { @@ -2317,6 +2439,11 @@ export function ProjectTree({ /> )} + {(catalogStatus.state === "opening" || catalogStatus.state === "rebuilding" || catalogStatus.repairPending > 0) && ( +
+ {t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total || "?" })} +
+ )} {compactTopics ? ( <> {renderProjectHeader("workbench")} diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index 4a4cff51b6..dfd19059ce 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -9,6 +9,7 @@ import type * as GeneratedApp from "../../wailsjs/go/main/App"; import type { InvocationRequest } from "./invocationDisplay"; import { addBreadcrumb } from "./breadcrumbs"; +import { asArray } from "./array"; import { maybeShare } from "./queryCoalesce"; import { t } from "./i18n"; import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels"; @@ -86,6 +87,12 @@ import type { PluginInstallOptions, PluginView, ProjectNode, + ProjectTreeSnapshot, + ProjectTopicPageRequest, + ProjectTopicPage, + ProjectTopicKey, + SessionCatalogStatus, + ProjectTreeChangedV2, PromptHistoryEntry, PromptHistoryResult, ProviderModelCatalogUpdate, @@ -569,6 +576,11 @@ export interface AppBindings { CloseTerminalForTab(tabID: string, sessionID: string): Promise; RenameTerminalForTab(tabID: string, sessionID: string, title: string): Promise; ListProjectTree(): Promise; + GetProjectTreeSnapshot(): Promise; + ListProjectTopics(req: ProjectTopicPageRequest): Promise; + GetTopicSummary(key: ProjectTopicKey): Promise; + GetSessionCatalogStatus(): Promise; + RebuildSessionCatalog(): Promise; RenameProject(workspaceRoot: string, title: string): Promise; SetProjectColor(workspaceRoot: string, color: string): Promise; SetProjectPinned(workspaceRoot: string, pinned: boolean): Promise; @@ -886,6 +898,21 @@ export function onProjectTreeChanged(cb: () => void): () => void { return () => {}; } +export function onProjectTreeChangedV2(cb: (event: ProjectTreeChangedV2) => void): () => void { + if (realApp() && typeof window !== "undefined" && window.runtime) { + return window.runtime.EventsOn("project-tree:changed-v2", (payload?: unknown) => { + if (!payload || typeof payload !== "object") return; + const event = payload as Partial; + cb({ + revision: typeof event.revision === "number" ? event.revision : 0, + roots: asArray(event.roots).filter((root): root is string => typeof root === "string"), + reason: typeof event.reason === "string" ? event.reason : "changed", + }); + }); + } + return () => {}; +} + // onTopicActivation subscribes to the "topic:activation" channel carrying the // lifecycle of ticketed StartTopicActivation requests (starting/ready/failed/ // cancelled). Returns an unsubscribe. @@ -5146,6 +5173,35 @@ function makeMockApp(): AppBindings { async ListProjectTree() { return cloneProjectTree(); }, + async GetProjectTreeSnapshot() { + return { + revision: 1, + projects: cloneProjectTree().map((project) => ({ ...project, children: [] })), + catalog: { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 }, + indexed: 4, + total: 4, + indexingDone: true, + }; + }, + async ListProjectTopics(req: ProjectTopicPageRequest) { + const folder = req.scope === "global" + ? cloneProjectTree().find((item) => item.kind === "global_folder") + : cloneProjectTree().find((item) => item.kind === "project" && item.root === req.workspaceRoot); + const query = (req.query ?? "").trim().toLocaleLowerCase(); + const all = asArray(folder?.children).filter((item) => !query || item.label.toLocaleLowerCase().includes(query)); + const start = Math.max(0, Number.parseInt(req.cursor ?? "0", 10) || 0); + const limit = Math.min(200, Math.max(1, req.limit ?? 50)); + const items = all.slice(start, start + limit); + return { items, nextCursor: start + items.length < all.length ? String(start + items.length) : undefined, revision: 1 }; + }, + async GetTopicSummary(key: ProjectTopicKey) { + const page = await this.ListProjectTopics({ scope: key.scope, workspaceRoot: key.workspaceRoot, limit: 200 }); + return page.items.find((item) => item.topicId === key.topicId) ?? { key: "", kind: key.scope === "global" ? "global_topic" : "topic", label: "", children: [] }; + }, + async GetSessionCatalogStatus() { + return { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 }; + }, + async RebuildSessionCatalog() {}, async RenameProject(workspaceRoot: string, title: string) { const node = workspaceRoot ? mockProjectTree.find((item) => item.root === workspaceRoot) diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index 2fc8d705d5..4b54313ae3 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -441,6 +441,8 @@ export interface ProjectNode { sessionPath?: string; projectColor?: string; turns?: number; + turnsState?: "unknown" | "valid" | "corrupt" | string; + health?: "ok" | "missing" | "corrupt" | "degraded" | string; createdAt?: number; lastActivityAt?: number; open?: boolean; @@ -455,6 +457,53 @@ export interface ProjectNode { children?: ProjectNode[]; } +export interface SessionCatalogStatus { + state: "opening" | "ready" | "degraded" | "rebuilding" | "closed" | string; + mode?: "disk" | "memory" | string; + revision: number; + indexed: number; + total: number; + repairPending: number; + lastError?: string; + quarantinedPath?: string; +} + +export interface ProjectTreeSnapshot { + revision: number; + projects: ProjectNode[]; + catalog: SessionCatalogStatus; + indexed: number; + total: number; + indexingDone: boolean; +} + +export interface ProjectTopicPageRequest { + scope: "global" | "project" | string; + workspaceRoot?: string; + cursor?: string; + limit?: number; + query?: string; + timeFilter?: string; +} + +export interface ProjectTopicPage { + items: ProjectNode[]; + nextCursor?: string; + revision: number; +} + +export interface ProjectTopicKey { + scope: "global" | "project" | string; + workspaceRoot?: string; + topicId: string; +} + +export interface ProjectTreeChangedV2 { + revision: number; + roots: string[]; + reason: string; +} + export interface DeliveryWorktreeAvailability { available: boolean; reason?: string; @@ -771,6 +820,7 @@ export interface SessionMeta { preview: string; title?: string; // user-chosen name; falls back to preview when empty turns: number; + turnsState?: "unknown" | "valid" | "corrupt" | string; createdAt: number; // unix milliseconds lastActivityAt: number; // unix milliseconds modTime: number; // compatibility alias for lastActivityAt @@ -799,6 +849,7 @@ export interface SessionReference { title: string; preview?: string; turns?: number; + turnsState?: "unknown" | "valid" | "corrupt" | string; createdAt?: number; lastActivityAt?: number; } diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 516b973a88..883a644c44 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -1182,6 +1182,7 @@ export const en = { "history.deleted": "deleted", "history.turnOne": "{n} turn", "history.turnOther": "{n} turns", + "history.indexing": "Indexing history", "history.confirmDelete": "Confirm delete", "history.confirmPurge": "Confirm permanent delete", "history.clearTrash": "Empty trash", @@ -1242,6 +1243,9 @@ export const en = { "projectTree.workspaceTitle": "Projects", "projectTree.pinnedTitle": "Pinned", "projectTree.searchPlaceholder": "Search projects or sessions", + "projectTree.indexing": "Organizing history", + "projectTree.indexingProgress": "Organizing history ({done}/{total})", + "projectTree.loadMore": "Load more", "projectTree.newTopic": "New session", "projectTree.createWorktree": "Create isolated Delivery workspace", "projectTree.createWorktreeHint": "Create a separate Git branch and worktree so Delivery sessions can write in parallel.", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 89e15053d6..14e441b6e8 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -926,6 +926,7 @@ export const zhTW: Record = { "history.deleted": "已刪除", "history.turnOne": "{n} 輪", "history.turnOther": "{n} 輪", + "history.indexing": "正在整理歷史", "history.confirmDelete": "確認刪除", "history.confirmPurge": "確認徹底刪除", "history.clearTrash": "清空回收站", @@ -986,6 +987,9 @@ export const zhTW: Record = { "projectTree.workspaceTitle": "專案", "projectTree.pinnedTitle": "置頂", "projectTree.searchPlaceholder": "搜尋專案或會話", + "projectTree.indexing": "正在整理歷史", + "projectTree.indexingProgress": "正在整理歷史({done}/{total})", + "projectTree.loadMore": "載入更多", "projectTree.newTopic": "新建會話", "projectTree.createWorktree": "建立隔離的交付工作區", "projectTree.createWorktreeHint": "建立獨立的 Git 分支與 worktree,讓多個交付會話安全並行寫入。", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index de7d458322..fcbeed7997 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -1183,6 +1183,7 @@ export const zh: Record = { "history.deleted": "已删除", "history.turnOne": "{n} 轮", "history.turnOther": "{n} 轮", + "history.indexing": "正在整理历史", "history.confirmDelete": "确认删除", "history.confirmPurge": "确认彻底删除", "history.clearTrash": "清空回收站", @@ -1243,6 +1244,9 @@ export const zh: Record = { "projectTree.workspaceTitle": "项目", "projectTree.pinnedTitle": "置顶", "projectTree.searchPlaceholder": "搜索项目或会话", + "projectTree.indexing": "正在整理历史", + "projectTree.indexingProgress": "正在整理历史({done}/{total})", + "projectTree.loadMore": "加载更多", "projectTree.newTopic": "新会话", "projectTree.createWorktree": "创建隔离的交付工作区", "projectTree.createWorktreeHint": "创建独立的 Git 分支和 worktree,让多个交付会话安全并行写入。", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 71b2f59cbf..c616d0d53e 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -24670,6 +24670,13 @@ body > .mermaid-diagram--fullscreen { font-size: 13px; } +.project-tree__catalog-progress { + padding: 4px 10px 6px; + color: var(--fg-muted); + font-size: 11px; + line-height: 1.35; +} + .project-tree__empty--subtle { padding: 8px 12px; font-size: 12px; diff --git a/desktop/go.mod b/desktop/go.mod index b4c3052e50..6898075d7f 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -37,6 +37,7 @@ require ( github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -52,13 +53,15 @@ require ( github.com/leaanthony/slicer v1.6.0 // indirect github.com/leaanthony/u v1.1.1 // indirect github.com/mattn/go-colorable v0.1.15 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-pointer v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/sftp v1.13.11 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect github.com/samber/lo v1.53.0 // indirect @@ -77,6 +80,10 @@ require ( golang.org/x/crypto v0.54.0 // indirect golang.org/x/net v0.57.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.56.0 // indirect mvdan.cc/sh/v3 v3.13.1 // indirect ) diff --git a/desktop/go.sum b/desktop/go.sum index 5ce413ac94..220e53a907 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -25,6 +25,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -37,11 +39,15 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -77,12 +83,14 @@ github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -93,6 +101,8 @@ github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -203,6 +213,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -212,5 +224,33 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/desktop/hang_watchdog_darwin.go b/desktop/hang_watchdog_darwin.go index 894825387d..dc2c2ff707 100644 --- a/desktop/hang_watchdog_darwin.go +++ b/desktop/hang_watchdog_darwin.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && cgo package main diff --git a/desktop/hang_watchdog_darwin_nocgo.go b/desktop/hang_watchdog_darwin_nocgo.go new file mode 100644 index 0000000000..d02fb38c48 --- /dev/null +++ b/desktop/hang_watchdog_darwin_nocgo.go @@ -0,0 +1,9 @@ +//go:build darwin && !cgo + +package main + +func mainThreadWatchdogSupported() bool { return false } + +func startNativeMainThreadHeartbeat(uint64) {} + +func stopNativeMainThreadHeartbeat() {} diff --git a/desktop/session_catalog.go b/desktop/session_catalog.go new file mode 100644 index 0000000000..8838e1dd9a --- /dev/null +++ b/desktop/session_catalog.go @@ -0,0 +1,902 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "path/filepath" + "sort" + "strings" + "time" + + "reasonix/internal/config" + "reasonix/internal/control" + "reasonix/internal/sessioncatalog" +) + +type SessionCatalogStatus struct { + State string `json:"state"` + Mode string `json:"mode"` + Revision uint64 `json:"revision"` + Indexed int64 `json:"indexed"` + Total int64 `json:"total"` + RepairPending int64 `json:"repairPending"` + LastError string `json:"lastError,omitempty"` + QuarantinedPath string `json:"quarantinedPath,omitempty"` +} + +type ProjectTreeSnapshot struct { + Revision uint64 `json:"revision"` + Projects []ProjectNode `json:"projects"` + Catalog SessionCatalogStatus `json:"catalog"` + Indexed int64 `json:"indexed"` + Total int64 `json:"total"` + IndexingDone bool `json:"indexingDone"` +} + +type ProjectTopicPageRequest struct { + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` + Query string `json:"query,omitempty"` + TimeFilter string `json:"timeFilter,omitempty"` +} + +type ProjectTopicKey struct { + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + TopicID string `json:"topicId"` +} + +type ProjectTopicPage struct { + Items []ProjectNode `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` + Revision uint64 `json:"revision"` +} + +type ProjectTreeChangedV2 struct { + Revision uint64 `json:"revision"` + Roots []string `json:"roots"` + Reason string `json:"reason"` +} + +func sessionCatalogStatus(status sessioncatalog.Status) SessionCatalogStatus { + return SessionCatalogStatus{ + State: string(status.State), + Mode: string(status.Mode), + Revision: status.Revision, + Indexed: status.Indexed, + Total: status.Total, + RepairPending: status.RepairPending, + LastError: status.LastError, + QuarantinedPath: status.QuarantinedPath, + } +} + +func (a *App) currentSessionCatalogStatus() SessionCatalogStatus { + if a == nil { + return SessionCatalogStatus{State: string(sessioncatalog.StateDegraded), Mode: string(sessioncatalog.ModeMemory)} + } + if a.catalogRebuilding.Load() { + return SessionCatalogStatus{State: string(sessioncatalog.StateRebuilding)} + } + if catalog := a.sessionCatalog.Load(); catalog != nil { + return sessionCatalogStatus(catalog.Status()) + } + return SessionCatalogStatus{State: string(sessioncatalog.StateOpening)} +} + +func (a *App) startSessionCatalog(rebuild bool) { + if a == nil || a.shuttingDown.Load() { + return + } + a.catalogLifecycleMu.Lock() + if a.catalogCancel != nil { + a.catalogLifecycleMu.Unlock() + return + } + ctx, cancel := context.WithCancel(a.bootContext()) + done := make(chan struct{}) + a.catalogCancel = cancel + a.catalogDone = done + a.catalogRebuilding.Store(rebuild) + a.catalogLifecycleMu.Unlock() + + go func() { + defer close(done) + defer a.catalogRebuilding.Store(false) + path := sessioncatalog.DefaultPath() + targets := a.sessionCatalogTargets() + if rebuild { + if _, err := sessioncatalog.Rebuild(ctx, path, targets); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("desktop: rebuild session catalog", "err", err) + } + } + catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{ + Path: path, + OnRevision: func(revision uint64, roots []string, reason string) { + a.emitProjectTreeChangedV2(revision, roots, reason) + }, + }) + if err != nil { + slog.Warn("desktop: open session catalog", "err", err) + return + } + if ctx.Err() != nil || a.shuttingDown.Load() { + closeCtx, closeCancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + _ = catalog.Close(closeCtx) + closeCancel() + return + } + a.sessionCatalog.Store(catalog) + if err := a.syncSessionCatalogMetadata(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("desktop: sync session catalog metadata", "err", err) + } + select { + case <-a.tabsRestoredSignal(): + case <-ctx.Done(): + return + } + a.indexRestoredSessionPaths(ctx, catalog) + for _, target := range targets { + if ctx.Err() != nil || a.shuttingDown.Load() { + return + } + // Legacy assignment is deliberately background-only. It can scan and + // repair old metadata, but no project-tree or controller request waits. + if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 { + _ = a.syncSessionCatalogMetadata(ctx, catalog) + } + if err := catalog.ReconcileDirectory(ctx, target); err != nil && !errors.Is(err, context.Canceled) { + slog.Debug("desktop: reconcile session catalog directory", "dir", target.Path, "err", err) + } + } + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := a.syncSessionCatalogMetadata(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) { + slog.Debug("desktop: refresh session catalog metadata", "err", err) + } + for _, target := range a.sessionCatalogTargets() { + if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 { + _ = a.syncSessionCatalogMetadata(ctx, catalog) + } + catalog.RequestReconcile(target) + } + case <-ctx.Done(): + return + } + } + }() +} + +func (a *App) stopSessionCatalog(timeout time.Duration) { + if a == nil { + return + } + a.catalogLifecycleMu.Lock() + cancel := a.catalogCancel + done := a.catalogDone + a.catalogCancel = nil + a.catalogDone = nil + a.catalogLifecycleMu.Unlock() + if cancel != nil { + cancel() + } + catalog := a.sessionCatalog.Swap(nil) + deadline := time.Now().Add(timeout) + if catalog != nil { + remaining := time.Until(deadline) + if remaining < 0 { + remaining = 0 + } + ctx, closeCancel := context.WithTimeout(context.Background(), remaining) + _ = catalog.Close(ctx) + closeCancel() + } + if done != nil { + remaining := time.Until(deadline) + if remaining <= 0 { + return + } + timer := time.NewTimer(remaining) + defer timer.Stop() + select { + case <-done: + case <-timer.C: + } + } +} + +func (a *App) cancelAllTabBuilds() { + if a == nil { + return + } + a.mu.Lock() + for _, tab := range a.tabs { + a.supersedeTabBuildLocked(tab) + } + for _, tab := range a.detachedSessions { + a.supersedeTabBuildLocked(tab) + } + a.mu.Unlock() +} + +func (a *App) sessionCatalogTargets() []sessioncatalog.DirectoryTarget { + f := loadProjectsFile() + seen := map[string]bool{} + out := []sessioncatalog.DirectoryTarget{} + add := func(target sessioncatalog.DirectoryTarget) { + target.Path = filepath.Clean(strings.TrimSpace(target.Path)) + if target.Path == "." || target.Path == "" || seen[target.Path] { + return + } + seen[target.Path] = true + out = append(out, target) + } + add(sessioncatalog.DirectoryTarget{Path: config.SessionDir(), Scope: "global"}) + add(sessioncatalog.DirectoryTarget{Path: desktopSessionDir(globalWorkspaceRoot()), Scope: "global"}) + for _, project := range f.Projects { + add(sessioncatalog.DirectoryTarget{Path: desktopSessionDir(project.Root), Scope: "project", WorkspaceRoot: project.Root}) + } + return out +} + +func (a *App) indexRestoredSessionPaths(ctx context.Context, catalog *sessioncatalog.Catalog) { + type restored struct { + target sessioncatalog.DirectoryTarget + path string + } + a.mu.RLock() + items := make([]restored, 0, len(a.tabs)+len(a.detachedSessions)) + collect := func(tab *WorkspaceTab) { + if tab == nil || strings.TrimSpace(tab.SessionPath) == "" { + return + } + items = append(items, restored{ + target: sessioncatalog.DirectoryTarget{Path: filepath.Dir(tab.SessionPath), Scope: tab.Scope, WorkspaceRoot: tab.WorkspaceRoot}, + path: tab.SessionPath, + }) + } + for _, tab := range a.tabs { + collect(tab) + } + for _, tab := range a.detachedSessions { + collect(tab) + } + a.mu.RUnlock() + for _, item := range items { + if ctx.Err() != nil { + return + } + _ = catalog.IndexSessionPath(ctx, item.target, item.path) + } +} + +func (a *App) syncSessionCatalogMetadata(ctx context.Context, catalog *sessioncatalog.Catalog) error { + f := loadProjectsFile() + deleted := map[string]bool{} + for _, topicID := range f.DeletedTopics { + deleted[topicID] = true + } + projects := []sessioncatalog.ProjectRecord{{ + Scope: "global", Title: strings.TrimSpace(f.GlobalTitle), Color: normalizeProjectColor(f.GlobalColor), + }} + if projects[0].Title == "" { + projects[0].Title = "Global" + } + topics := []sessioncatalog.TopicMetadata{} + appendTopics := func(scope, root string, ids, pinnedIDs []string) { + titles := loadTopicTitles(root) + sources := loadTopicTitleSources(root) + created := loadTopicCreatedAts(root) + ordered := pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs) + for index, topicID := range ordered { + if deleted[topicID] { + continue + } + title := strings.TrimSpace(titles[topicID]) + if title == "" { + title = defaultTopicTitle + } + topics = append(topics, sessioncatalog.TopicMetadata{ + Scope: scope, WorkspaceRoot: root, TopicID: topicID, Title: title, + TitleSource: sources[topicID], Pinned: containsDesktopString(pinnedIDs, topicID), + SortOrder: index, CreatedAt: topicCreatedAtForTree(created, topicID), + }) + } + } + appendTopics("global", "", f.GlobalTopics, f.GlobalPinnedTopics) + for index, project := range f.Projects { + title := strings.TrimSpace(project.Title) + if title == "" { + title = workspaceName(project.Root) + } + projects = append(projects, sessioncatalog.ProjectRecord{ + Scope: "project", WorkspaceRoot: project.Root, Title: title, Color: project.Color, + Pinned: containsDesktopString(f.PinnedProjects, project.Root), SortOrder: index, + }) + appendTopics("project", project.Root, project.Topics, project.PinnedTopics) + } + return catalog.SyncMetadata(ctx, projects, topics) +} + +func (a *App) emitProjectTreeChangedV2(revision uint64, roots []string, reason string) { + if roots == nil { + roots = []string{} + } + a.emitRuntimeEvent("project-tree:changed-v2", ProjectTreeChangedV2{Revision: revision, Roots: roots, Reason: reason}) + // One-release compatibility event. Its wrapper is catalog-only, so legacy + // frontends refresh without reintroducing synchronous history I/O. + a.emitRuntimeEvent("project-tree:changed") +} + +func (a *App) requestSessionCatalogReconcile(dir string) { + catalog := a.sessionCatalog.Load() + if catalog == nil || a.shuttingDown.Load() || strings.TrimSpace(dir) == "" { + return + } + clean := filepath.Clean(dir) + target := sessioncatalog.DirectoryTarget{Path: clean, Scope: "global"} + for _, candidate := range a.sessionCatalogTargets() { + if sameDesktopPath(candidate.Path, clean) { + target = candidate + break + } + } + go func() { + if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 { + ctx, cancel := context.WithTimeout(a.bootContext(), 5*time.Second) + _ = a.syncSessionCatalogMetadata(ctx, catalog) + cancel() + } + catalog.RequestReconcile(target) + }() +} + +func sessionDirectoryForPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + return filepath.Dir(path) +} + +func (a *App) requestSessionCatalogPath(scope, workspaceRoot, path string) { + catalog := a.sessionCatalog.Load() + if catalog == nil || a.shuttingDown.Load() || strings.TrimSpace(path) == "" { + return + } + catalog.RequestIndexSession(sessioncatalog.DirectoryTarget{ + Path: sessionDirectoryForPath(path), Scope: scope, WorkspaceRoot: workspaceRoot, + }, path) +} + +func (a *App) removeSessionCatalogPath(path, reason string) { + catalog := a.sessionCatalog.Load() + if catalog == nil || strings.TrimSpace(path) == "" { + return + } + ctx, cancel := context.WithTimeout(a.bootContext(), 150*time.Millisecond) + defer cancel() + if err := catalog.RemoveSession(ctx, path, reason); err != nil && !errors.Is(err, context.Canceled) { + slog.Debug("desktop: remove session catalog row", "err", err) + } +} + +func (a *App) requestSessionCatalogMetadataSync() { + catalog := a.sessionCatalog.Load() + if catalog == nil || a.shuttingDown.Load() { + return + } + go func() { + ctx, cancel := context.WithTimeout(a.bootContext(), 5*time.Second) + defer cancel() + _ = a.syncSessionCatalogMetadata(ctx, catalog) + }() +} + +func (a *App) GetProjectTreeSnapshot() ProjectTreeSnapshot { + f := loadProjectsFile() + projects := []ProjectNode{} + if strings.TrimSpace(f.GlobalTitle) != "" || len(f.GlobalTopics) > 0 || len(f.Projects) == 0 { + label := strings.TrimSpace(f.GlobalTitle) + if label == "" { + label = "Global" + } + projects = append(projects, ProjectNode{ + Key: "global_folder", Kind: "global_folder", Label: label, + Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor), + Children: []ProjectNode{}, + }) + } + for _, project := range f.Projects { + label := strings.TrimSpace(project.Title) + if label == "" { + label = workspaceName(project.Root) + } + projects = append(projects, ProjectNode{ + Key: "project_" + project.Root, Kind: "project", Label: label, + Root: project.Root, ProjectColor: project.Color, + Pinned: containsDesktopString(f.PinnedProjects, project.Root), + Children: []ProjectNode{}, + }) + } + projects = applyPinnedProjectOrder(applyProjectTreeOrder(projects, f.SidebarOrder), f.PinnedProjects) + status := a.currentSessionCatalogStatus() + return ProjectTreeSnapshot{ + Revision: status.Revision, Projects: projects, Catalog: status, + Indexed: status.Indexed, Total: status.Total, + IndexingDone: status.State == string(sessioncatalog.StateReady) && status.RepairPending == 0, + } +} + +type catalogRuntimeSnapshot struct { + scope string + workspaceRoot string + topicID string + sessionPath string + activity string + topicTitle string + ctrl control.SessionAPI + open bool +} + +type catalogRuntimeOverlay struct { + open bool + running bool + status string +} + +func catalogRuntimeStatus(activity string, runtimeStatus control.RuntimeStatus) string { + status := normalizeTopicStatus(activity) + if runtimeStatus.PendingPrompt { + return topicStatusWaitingConfirmation + } + if runtimeStatus.Running { + if status == "" || status == topicStatusError || status == topicStatusPaused { + return topicStatusThinking + } + return status + } + if runtimeStatus.BackgroundJobs > 0 { + return topicStatusBackgroundJob + } + if status == topicStatusError || status == topicStatusPaused { + return status + } + return status +} + +func (a *App) catalogRuntimeOverlays() (map[string]catalogRuntimeOverlay, map[string]catalogRuntimeOverlay) { + a.mu.RLock() + snapshots := make([]catalogRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions)) + collect := func(tab *WorkspaceTab, open bool) { + if tab == nil || strings.TrimSpace(tab.TopicID) == "" { + return + } + snapshots = append(snapshots, catalogRuntimeSnapshot{ + scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID, + sessionPath: tab.SessionPath, activity: tab.ActivityStatus, topicTitle: tab.TopicTitle, + ctrl: tab.Ctrl, open: open, + }) + } + for _, tab := range a.tabs { + collect(tab, true) + } + for _, tab := range a.detachedSessions { + collect(tab, false) + } + a.mu.RUnlock() + topics := map[string]catalogRuntimeOverlay{} + sessions := map[string]catalogRuntimeOverlay{} + for _, snap := range snapshots { + runtimeStatus := control.RuntimeStatus{} + path := strings.TrimSpace(snap.sessionPath) + if snap.ctrl != nil { + runtimeStatus = snap.ctrl.RuntimeStatus() + if path == "" { + path = snap.ctrl.SessionPath() + } + } + status := catalogRuntimeStatus(snap.activity, runtimeStatus) + running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0 + overlay := catalogRuntimeOverlay{open: snap.open, running: running, status: status} + key := topicSummaryKey(snap.scope, snap.workspaceRoot, snap.topicID) + current := topics[key] + current.open = current.open || overlay.open + current.running = current.running || overlay.running + if current.status == "" { + current.status = overlay.status + } + topics[key] = current + if path != "" { + sessions[sessionRuntimeKey(path)] = overlay + } + } + return topics, sessions +} + +func (a *App) metadataProjectTopics(scope, workspaceRoot string) []ProjectNode { + f := loadProjectsFile() + deleted := map[string]bool{} + for _, topicID := range f.DeletedTopics { + deleted[topicID] = true + } + ids := f.GlobalTopics + pinnedIDs := f.GlobalPinnedTopics + titleRoot := "" + projectColor := normalizeProjectColor(f.GlobalColor) + if scope == "project" { + ids = nil + pinnedIDs = nil + titleRoot = workspaceRoot + for _, project := range f.Projects { + if sameProjectRoot(project.Root, workspaceRoot) { + ids = project.Topics + pinnedIDs = project.PinnedTopics + projectColor = project.Color + break + } + } + } + titles := loadTopicTitles(titleRoot) + sources := loadTopicTitleSources(titleRoot) + created := loadTopicCreatedAts(titleRoot) + topicOverlays, _ := a.catalogRuntimeOverlays() + runtimeNodes := a.runtimeOnlyProjectTopics(scope, workspaceRoot) + runtimeByTopic := map[string]ProjectNode{} + for _, node := range runtimeNodes { + runtimeByTopic[node.TopicID] = node + } + out := []ProjectNode{} + seen := map[string]bool{} + for _, topicID := range pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs) { + if deleted[topicID] { + continue + } + seen[topicID] = true + title := strings.TrimSpace(titles[topicID]) + if title == "" { + title = defaultTopicTitle + } + kind := "topic" + if scope != "project" { + kind = "global_topic" + } + overlay := topicOverlays[topicSummaryKey(scope, workspaceRoot, topicID)] + node := ProjectNode{ + Key: kind + "_" + topicID, Kind: kind, + Label: a.localizedTopicTitle(title, sources[topicID]), Root: workspaceRoot, + TopicID: topicID, ProjectColor: projectColor, + CreatedAt: topicCreatedAtForTree(created, topicID), Pinned: containsDesktopString(pinnedIDs, topicID), + Open: overlay.open, Running: overlay.running, Status: overlay.status, + TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK), + Children: []ProjectNode{}, + } + if runtimeNode, ok := runtimeByTopic[topicID]; ok { + node.Open = runtimeNode.Open + node.Running = runtimeNode.Running + node.Status = runtimeNode.Status + node.Children = runtimeNode.Children + } + out = append(out, node) + } + for _, runtimeNode := range runtimeNodes { + if seen[runtimeNode.TopicID] || deleted[runtimeNode.TopicID] { + continue + } + out = append(out, runtimeNode) + } + return out +} + +func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNode { + a.mu.RLock() + snapshots := []catalogRuntimeSnapshot{} + collect := func(tab *WorkspaceTab, open bool) { + if tab == nil || strings.TrimSpace(tab.TopicID) == "" { + return + } + if scope == "project" { + if tab.Scope != "project" || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) { + return + } + } else if tab.Scope == "project" { + return + } + snapshots = append(snapshots, catalogRuntimeSnapshot{ + scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID, + sessionPath: tab.SessionPath, activity: tab.ActivityStatus, + topicTitle: tab.TopicTitle, ctrl: tab.Ctrl, open: open, + }) + } + for _, tab := range a.tabs { + collect(tab, true) + } + for _, tab := range a.detachedSessions { + collect(tab, false) + } + a.mu.RUnlock() + byTopic := map[string][]catalogRuntimeSnapshot{} + for _, snapshot := range snapshots { + if snapshot.sessionPath == "" && snapshot.ctrl != nil { + snapshot.sessionPath = snapshot.ctrl.SessionPath() + } + byTopic[snapshot.topicID] = append(byTopic[snapshot.topicID], snapshot) + } + topicIDs := make([]string, 0, len(byTopic)) + for topicID := range byTopic { + topicIDs = append(topicIDs, topicID) + } + sort.Strings(topicIDs) + out := []ProjectNode{} + for _, topicID := range topicIDs { + sessions := byTopic[topicID] + kind := "topic" + sessionKind := "session" + if scope != "project" { + kind = "global_topic" + sessionKind = "global_session" + } + label := defaultTopicTitle + if strings.TrimSpace(sessions[0].topicTitle) != "" { + label = sessions[0].topicTitle + } + node := ProjectNode{ + Key: kind + "_" + topicID, Kind: kind, Label: label, + Root: workspaceRoot, TopicID: topicID, TurnsState: string(sessioncatalog.TurnsUnknown), + Health: string(sessioncatalog.HealthOK), Children: []ProjectNode{}, + } + for _, session := range sessions { + runtimeStatus := control.RuntimeStatus{} + if session.ctrl != nil { + runtimeStatus = session.ctrl.RuntimeStatus() + } + status := catalogRuntimeStatus(session.activity, runtimeStatus) + running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0 + if len(sessions) == 1 { + node.Open = session.open + node.Running = running + node.Status = status + continue + } + path := strings.TrimSpace(session.sessionPath) + sessionLabel := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if sessionLabel == "" || sessionLabel == "." { + sessionLabel = label + } + node.Children = append(node.Children, ProjectNode{ + Key: projectSessionNodeKey(scope, path), Kind: sessionKind, Label: sessionLabel, + Root: workspaceRoot, TopicID: topicID, SessionPath: path, + Open: session.open, Running: running, Status: status, + TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK), + Children: []ProjectNode{}, + }) + } + out = append(out, node) + } + return out +} + +func (a *App) metadataTopicPage(req ProjectTopicPageRequest) ProjectTopicPage { + items := a.metadataProjectTopics(req.Scope, req.WorkspaceRoot) + query := strings.ToLower(strings.TrimSpace(req.Query)) + if query != "" { + filtered := items[:0] + for _, item := range items { + if strings.Contains(strings.ToLower(item.Label), query) { + filtered = append(filtered, item) + } + } + items = filtered + } + start := 0 + if strings.HasPrefix(req.Cursor, "meta:") { + lastID := strings.TrimPrefix(req.Cursor, "meta:") + for index, item := range items { + if item.TopicID == lastID { + start = index + 1 + break + } + } + } + limit := req.Limit + if limit <= 0 { + limit = sessioncatalog.DefaultLimit + } + if limit > sessioncatalog.MaxLimit { + limit = sessioncatalog.MaxLimit + } + end := min(start+limit, len(items)) + page := ProjectTopicPage{Items: append([]ProjectNode(nil), items[start:end]...)} + if end < len(items) && end > start { + page.NextCursor = "meta:" + items[end-1].TopicID + } + return page +} + +func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay) ProjectNode { + kind := "topic" + if topic.Scope == "global" { + kind = "global_topic" + } + overlay := topicOverlays[topicSummaryKey(topic.Scope, topic.WorkspaceRoot, topic.TopicID)] + node := ProjectNode{ + Key: kind + "_" + topic.TopicID, Kind: kind, Label: a.localizedTopicTitle(topic.Title, ""), + Root: topic.WorkspaceRoot, TopicID: topic.TopicID, Turns: topic.Turns, + TurnsState: string(topic.TurnsState), Health: string(topic.Health), + CreatedAt: topic.CreatedAt, LastActivityAt: topic.LastActivityAt, + Pinned: topic.Pinned, Open: overlay.open, Running: overlay.running, Status: overlay.status, + Children: []ProjectNode{}, + } + if len(topic.Sessions) <= 1 { + return node + } + for _, session := range topic.Sessions { + sessionKind := "session" + if topic.Scope == "global" { + sessionKind = "global_session" + } + sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)] + label := strings.TrimSpace(session.CustomTitle) + if label == "" { + label = strings.TrimSpace(session.Preview) + } + if label == "" { + label = filepath.Base(session.Path) + } + node.Children = append(node.Children, ProjectNode{ + Key: projectSessionNodeKey(topic.Scope, session.Path), Kind: sessionKind, + Label: label, Root: topic.WorkspaceRoot, TopicID: topic.TopicID, + SessionPath: session.Path, Turns: session.Turns, + TurnsState: string(session.TurnsState), Health: string(session.Health), + CreatedAt: session.CreatedAt, LastActivityAt: session.LastActivityAt, + Open: sessionOverlay.open, Running: sessionOverlay.running, Status: sessionOverlay.status, + Recovered: session.Recovered, RecoveryReason: session.RecoveryReason, + RecoveryDigest: session.RecoveryDigest, RecoveryParentID: session.ParentID, + Children: []ProjectNode{}, + }) + } + return node +} + +func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage, error) { + out := ProjectTopicPage{Items: []ProjectNode{}} + catalog := a.sessionCatalog.Load() + if catalog == nil { + return a.metadataTopicPage(req), nil + } + page, err := catalog.ListTopics(a.bootContext(), sessioncatalog.TopicPageRequest{ + Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: req.Cursor, + Limit: req.Limit, Query: req.Query, TimeFilter: req.TimeFilter, + }) + if err != nil { + return out, err + } + topicOverlays, sessionOverlays := a.catalogRuntimeOverlays() + for _, topic := range page.Items { + out.Items = append(out.Items, a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays)) + } + out.NextCursor = page.NextCursor + out.Revision = page.Revision + return out, nil +} + +func (a *App) GetTopicSummary(key ProjectTopicKey) (ProjectNode, error) { + if catalog := a.sessionCatalog.Load(); catalog != nil { + topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{ + Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID, + }) + if err != nil { + return ProjectNode{Children: []ProjectNode{}}, err + } + if ok { + topicOverlays, sessionOverlays := a.catalogRuntimeOverlays() + return a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays), nil + } + } + page, err := a.ListProjectTopics(ProjectTopicPageRequest{ + Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, Limit: sessioncatalog.MaxLimit, + }) + if err != nil { + return ProjectNode{Children: []ProjectNode{}}, err + } + for _, node := range page.Items { + if node.TopicID == key.TopicID { + return node, nil + } + } + return ProjectNode{Children: []ProjectNode{}}, nil +} + +func (a *App) GetSessionCatalogStatus() SessionCatalogStatus { + return a.currentSessionCatalogStatus() +} + +func (a *App) RebuildSessionCatalog() error { + if a == nil || a.shuttingDown.Load() { + return errors.New("application is shutting down") + } + if !a.catalogRebuilding.CompareAndSwap(false, true) { + return nil + } + go func() { + a.stopSessionCatalog(250 * time.Millisecond) + a.catalogRebuilding.Store(false) + a.startSessionCatalog(true) + }() + return nil +} + +// ListProjectTree is the one-release compatibility wrapper. It composes only +// catalog pages and project shells; it never migrates, scans, or decodes a +// session synchronously. +func (a *App) ListProjectTree() []ProjectNode { + snapshot := a.GetProjectTreeSnapshot() + hasGlobal := false + for _, project := range snapshot.Projects { + if project.Kind == "global_folder" { + hasGlobal = true + break + } + } + if !hasGlobal && len(a.metadataProjectTopics("global", "")) > 0 { + f := loadProjectsFile() + label := strings.TrimSpace(f.GlobalTitle) + if label == "" { + label = "Global" + } + snapshot.Projects = append(snapshot.Projects, ProjectNode{ + Key: "global_folder", Kind: "global_folder", Label: label, + Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor), Children: []ProjectNode{}, + }) + snapshot.Projects = applyPinnedProjectOrder(applyProjectTreeOrder(snapshot.Projects, f.SidebarOrder), f.PinnedProjects) + } + for index := range snapshot.Projects { + project := &snapshot.Projects[index] + scope := "project" + root := project.Root + if project.Kind == "global_folder" { + scope = "global" + root = "" + } + cursor := "" + for { + page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Cursor: cursor, Limit: sessioncatalog.MaxLimit}) + if err != nil { + break + } + project.Children = append(project.Children, page.Items...) + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + } + if len(project.Children) == 0 { + project.Children = a.metadataProjectTopics(scope, root) + } + } + return snapshot.Projects +} + +func (a *App) catalogSessionPathForTopic(scope, workspaceRoot, topicID string) string { + if strings.TrimSpace(topicID) == "" { + return "" + } + catalog := a.sessionCatalog.Load() + if catalog == nil { + return "" + } + topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID}) + if err != nil || !ok || len(topic.Sessions) == 0 { + return "" + } + sort.SliceStable(topic.Sessions, func(i, j int) bool { + return topic.Sessions[i].LastActivityAt > topic.Sessions[j].LastActivityAt + }) + return topic.Sessions[0].Path +} diff --git a/desktop/session_catalog_app_test.go b/desktop/session_catalog_app_test.go new file mode 100644 index 0000000000..da59b3dd45 --- /dev/null +++ b/desktop/session_catalog_app_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestProjectTreeSnapshotReturnsProjectShellWithoutMigratingSessions(t *testing.T) { + isolateDesktopUserDirs(t) + root := t.TempDir() + if err := addProject(root, "Large Project"); err != nil { + t.Fatal(err) + } + sessionDir := desktopSessionDir(root) + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + legacyPath := filepath.Join(sessionDir, "legacy.jsonl") + if err := os.WriteFile(legacyPath, []byte(`{"role":"user","content":"legacy"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + snapshot := NewApp().GetProjectTreeSnapshot() + if len(snapshot.Projects) != 1 || snapshot.Projects[0].Root != root { + t.Fatalf("snapshot = %#v, want project shell %q", snapshot, root) + } + if snapshot.Projects[0].Children == nil { + t.Fatal("project shell children encoded as null, want []") + } + if _, err := os.Stat(legacyPath + ".meta"); !os.IsNotExist(err) { + t.Fatalf("snapshot migrated session metadata: %v", err) + } +} + +func TestCompatibilityProjectTreeDoesNotMigrateLegacySession(t *testing.T) { + isolateDesktopUserDirs(t) + root := t.TempDir() + if err := addProject(root, "Project"); err != nil { + t.Fatal(err) + } + sessionDir := desktopSessionDir(root) + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + legacyPath := filepath.Join(sessionDir, "legacy.jsonl") + if err := os.WriteFile(legacyPath, []byte(`{"role":"user","content":"legacy"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _ = NewApp().ListProjectTree() + if _, err := os.Stat(legacyPath + ".meta"); !os.IsNotExist(err) { + t.Fatalf("ListProjectTree migrated legacy session: %v", err) + } +} diff --git a/desktop/settings_app.go b/desktop/settings_app.go index 9d54be6d3d..ddf6a4d061 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -1904,12 +1904,10 @@ func (a *App) rebuildSettingTurnLockedWithModel(setting string, tab *WorkspaceTa if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil { return err } - ensureWorkspace := a.ensureTabControllerWorkspace - if admissionHeld { - ensureWorkspace = a.ensureTabControllerWorkspaceAdmissionHeld - } - if err := ensureWorkspace(tab); err != nil { - return err + if !admissionHeld { + if err := a.ensureTabControllerWorkspace(tab); err != nil { + return err + } } prevPath := a.reconciledSessionPathForTab(tab) if prevPath == "" { @@ -1924,9 +1922,6 @@ func (a *App) rebuildSettingTurnLockedWithModel(setting string, tab *WorkspaceTa if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil { return err } - if err := ensureWorkspace(tab); err != nil { - return err - } var carried []provider.Message oldCtrl := a.controllerForTab(tab) diff --git a/desktop/startup_health_test.go b/desktop/startup_health_test.go index d9e180dc3b..8d2e7d6da9 100644 --- a/desktop/startup_health_test.go +++ b/desktop/startup_health_test.go @@ -87,6 +87,49 @@ func TestShutdownWaitsForRuntimeLifecycleMutation(t *testing.T) { } } +func TestShutdownDoesNotWaitForCancelledControllerBuild(t *testing.T) { + isolateDesktopUserDirs(t) + app := NewApp() + tab := app.createTabEntryWithID("global", "", "", "blocked-build") + app.mu.Lock() + app.tabs[tab.ID] = tab + app.tabOrder = []string{tab.ID} + app.activeTabID = tab.ID + app.mu.Unlock() + + started := make(chan struct{}) + release := make(chan struct{}) + app.tabBuildStartHook = func(string) { + close(started) + <-release + } + buildDone := make(chan struct{}) + go func() { + app.startTabControllerBuild(tab) + close(buildDone) + }() + <-started + + shutdownDone := make(chan struct{}) + go func() { + app.shutdown(context.Background()) + close(shutdownDone) + }() + select { + case <-shutdownDone: + close(release) + case <-time.After(750 * time.Millisecond): + close(release) + <-shutdownDone + t.Fatal("shutdown waited for a cancelled controller build") + } + select { + case <-buildDone: + case <-time.After(5 * time.Second): + t.Fatal("cancelled controller build did not finish") + } +} + // TestShutdownRecordsLKGOnlyAfterReady pins that last-known-good config is only // written after the window reached domReady. A quit before paint must not // rewrite the LKG snapshot from an incomplete boot. diff --git a/desktop/system_quit_darwin.go b/desktop/system_quit_darwin.go index 22c1c64a0d..eb1ebd99ed 100644 --- a/desktop/system_quit_darwin.go +++ b/desktop/system_quit_darwin.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && cgo package main diff --git a/desktop/system_quit_stub.go b/desktop/system_quit_stub.go index abc739fd35..c69ae2f048 100644 --- a/desktop/system_quit_stub.go +++ b/desktop/system_quit_stub.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin || !cgo package main diff --git a/desktop/tabs.go b/desktop/tabs.go index d13e364b28..4e71253f55 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -2483,7 +2483,7 @@ func (a *App) openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionP a.startTabControllerBuild(tab) if scope == "project" { - a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(sessionPath)) + a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(sessionPath)) } return enrichTabMeta(meta), nil } @@ -2748,7 +2748,7 @@ func (a *App) ensureBlankTab(scope, workspaceRoot, forcedTokenMode string) (TabM a.mu.Unlock() a.startTabControllerBuild(created) - a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(prePath)) + a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(prePath)) return enrichTabMeta(meta), nil } @@ -2804,7 +2804,7 @@ func (a *App) ensureBlankTab(scope, workspaceRoot, forcedTokenMode string) (TabM a.mu.Unlock() a.startTabControllerBuild(created) - a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(prePath)) + a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(prePath)) return enrichTabMeta(meta), nil } @@ -3501,7 +3501,7 @@ func (a *App) markTabRemovedLocked(tab *WorkspaceTab) { // rebuild paths, which serialize through runtimeRebuildMu instead and are // never superseded by generation bumps. Callers must hold a.mu. func (a *App) tabBuildSupersededLocked(tab *WorkspaceTab, generation uint64) bool { - if tab == nil || tab.removed || a.tabs[tab.ID] != tab { + if tab == nil || tab.removed || a.shuttingDown.Load() || a.tabs[tab.ID] != tab { return true } return generation != 0 && tab.buildGeneration != generation @@ -3626,10 +3626,6 @@ func (a *App) buildTabController(tab *WorkspaceTab) { a.buildTabControllerWithLoadedSession(tab, loadedTabSession{}) } -func (a *App) buildTabControllerAdmissionHeld(tab *WorkspaceTab) { - a.buildTabControllerWithLoadedSessionAdmissionHeld(tab, loadedTabSession{}) -} - type loadedTabSession struct { Path string Session *agent.Session @@ -3643,10 +3639,6 @@ func (a *App) buildTabControllerWithLoadedSession(tab *WorkspaceTab, loadedSessi a.buildTabControllerWithContext(tab, loadedSession, a.bootContext(), 0, nil) } -func (a *App) buildTabControllerWithLoadedSessionAdmissionHeld(tab *WorkspaceTab, loadedSession loadedTabSession) { - a.buildTabControllerWithContextAdmissionHeld(tab, loadedSession, a.bootContext(), 0, nil) -} - func (a *App) desktopNotificationSender() notify.Sender { if a == nil { return notify.NewPlatformSender() @@ -3711,7 +3703,7 @@ func (a *App) recordTabStartupFailure(tab *WorkspaceTab, buildGeneration uint64, // closeTabBuildDone signals waiters (topic-activation completions) that the // build owning buildGeneration has terminated. Every build funnels through -// buildTabControllerWithContextAdmissionHeld, whose deferred call guarantees +// buildTabControllerWithContextCore, whose deferred call guarantees // the channel startTabControllerBuild created is closed exactly once, on every // terminal path — success, failure, and superseded abandon alike. Synchronous // rebuild paths pass generation 0 and never created a channel. @@ -3728,16 +3720,13 @@ func (a *App) closeTabBuildDone(tab *WorkspaceTab, buildGeneration uint64) { } func (a *App) buildTabControllerWithContext(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) { - a.runtimeAdmissionMu.RLock() - defer a.runtimeAdmissionMu.RUnlock() - a.buildTabControllerWithContextAdmissionHeld(tab, loadedSession, buildCtx, buildGeneration, buildCancel) + a.buildTabControllerWithContextCore(tab, loadedSession, buildCtx, buildGeneration, buildCancel) } -// buildTabControllerWithContextAdmissionHeld is the build core for callers -// that already hold either side of runtimeAdmissionMu. Keeping acquisition in -// the outer wrapper prevents recursive RLock deadlocks when foreground turn -// admission repairs a stale workspace while an MCP lifecycle writer is queued. -func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) { +// buildTabControllerWithContextCore performs configuration, session routing, +// and extension boot outside runtimeAdmissionMu. Only publication enters the +// lifecycle barrier. +func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession loadedTabSession, buildCtx context.Context, buildGeneration uint64, buildCancel context.CancelFunc) { defer a.recoverToPending("buildTabController") keepBuildContext := false defer func() { @@ -3799,46 +3788,38 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load } sessionDir := desktopSessionDir(root) + if tabScope == "global" { + sessionDir = desktopSessionDir(globalWorkspaceRoot()) + } topicID := strings.TrimSpace(tabTopicID) - - // Assign Global topics to legacy sessions in the global session dir so - // imported history appears in the project tree regardless of which tab - // triggered the build (the migration now sends everything to global). - migratedGlobalTopics := migrateLegacySessionsIntoGlobalTopics(config.SessionDir()) - if len(migratedGlobalTopics) > 0 { - a.emitProjectTreeChangedForSessionDirs(config.SessionDir()) + pinnedPath, hasPinnedPath := pinnedTabSessionPathForBuild(tabScope, tabWorkspaceRoot, sessionDir, tabSessionPath) + if hasPinnedPath && agent.IsCleanupPending(pinnedPath) { + // Boot reconciliation may finish the pending deletion before the later + // resume step. Clear the local candidate now so the disappeared path is + // not mistaken for a deliberate empty placeholder afterward. + hasPinnedPath = false + pinnedPath = "" + tabSessionPath = "" } - if tabScope == "global" && topicID == "" && len(migratedGlobalTopics) > 0 { - topicID = migratedGlobalTopics[0] - topicTitle := topicTitleForTab("global", "", topicID) - topicSource := loadTopicTitleSource("", topicID) - a.mu.Lock() - if a.tabBuildSupersededLocked(tab, buildGeneration) { - a.mu.Unlock() - return - } - if strings.TrimSpace(tab.TopicID) == "" { - tab.TopicID = topicID - tab.TopicTitle = topicTitle - tab.topicTitleSource = topicSource - a.saveTabsLocked() - } else { - topicID = strings.TrimSpace(tab.TopicID) - } - a.mu.Unlock() + catalogTopicPath := "" + if hasPinnedPath { + // A restored tab's exact path is already known state, not history + // discovery. Keep legacy-directory and empty placeholder paths usable + // while the catalog is still opening or rebuilding. + sessionDir = filepath.Dir(pinnedPath) + } else { + catalogTopicPath = a.catalogSessionPathForTopic(tabScope, tabWorkspaceRoot, topicID) } - if topicID != "" { - if _, dir := a.findTopicSessionForTarget(tabScope, tabWorkspaceRoot, topicID); dir != "" { - sessionDir = dir - } + if !hasPinnedPath && catalogTopicPath != "" { + sessionDir = filepath.Dir(catalogTopicPath) } startupSessionPath := "" - if pinnedPath, ok := pinnedTabSessionPath(sessionDir, tabSessionPath); ok { + if hasPinnedPath { if !agent.IsCleanupPending(pinnedPath) { startupSessionPath = pinnedPath } - } else if topicID != "" { - startupSessionPath = findTopicSession(sessionDir, topicID) + } else if catalogTopicPath != "" { + startupSessionPath = catalogTopicPath } model := strings.TrimSpace(tabModel) @@ -3968,32 +3949,12 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load acquiredLeaseKey := "" restoredRuntime := buildRuntime if dir := ctrl.SessionDir(); dir != "" { - migratedTopics := migrateLegacySessionsIntoGlobalTopics(dir) - if len(migratedTopics) > 0 { - a.emitProjectTreeChangedForSessionDirs(dir) - } // Refresh the topic/session locals under the lock: a rebind or the // recovery callback may have rewritten them since the early snapshot. a.mu.RLock() tabTopicID = strings.TrimSpace(tab.TopicID) tabSessionPath = tab.SessionPath a.mu.RUnlock() - if tabScope == "global" && tabTopicID == "" && len(migratedTopics) > 0 { - topicID := migratedTopics[0] - topicTitle := topicTitleForTab("global", "", topicID) - topicSource := loadTopicTitleSource("", topicID) - a.mu.Lock() - if !a.tabBuildSupersededLocked(tab, buildGeneration) && strings.TrimSpace(tab.TopicID) == "" { - tab.TopicID = topicID - tab.TopicTitle = topicTitle - tab.topicTitleSource = topicSource - tabTopicID = topicID - a.saveTabsLocked() - } else { - tabTopicID = strings.TrimSpace(tab.TopicID) - } - a.mu.Unlock() - } var path string var resumeSession *agent.Session var resumeLoadErr error @@ -4007,7 +3968,7 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load resumeSession = loaded } if resumeLoadErr == nil && path == "" && tabTopicID != "" { - existingPath := findTopicSession(dir, tabTopicID) + existingPath := a.catalogSessionPathForTopic(tabScope, tabWorkspaceRoot, tabTopicID) if existingPath != "" { if loaded, err := loadResumableSession(existingPath); err == nil { path = existingPath @@ -4163,6 +4124,11 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load } } + // Lifecycle admission protects only the compare-and-publish boundary. Slow + // config, history, lease, and extension work above remains cancellable and + // cannot prevent shutdown from acquiring the write side. + a.runtimeAdmissionMu.RLock() + defer a.runtimeAdmissionMu.RUnlock() a.mu.Lock() if a.tabBuildSupersededLocked(tab, buildGeneration) { a.mu.Unlock() @@ -4587,6 +4553,10 @@ func (a *App) tabSnapshotLoop(tab *WorkspaceTab) { a.mu.RUnlock() if ctrl != nil { if err := a.snapshotTab(tab); err == nil { + a.mu.RLock() + scope, workspaceRoot := tab.Scope, tab.WorkspaceRoot + a.mu.RUnlock() + a.requestSessionCatalogPath(scope, workspaceRoot, ctrl.SessionPath()) if !a.maybeAutoTitleTopic(tab) { a.emitProjectTreeChangedForSessionDirs(ctrl.SessionDir()) } @@ -6301,7 +6271,7 @@ func (a *App) handleTabSessionRecovered(tab *WorkspaceTab) func(control.SessionR _ = saveTelemetry(info.RecoveryPath+".telemetry.json", tab.telemetrySnapshot()) } } - a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(info.RecoveryPath)) + a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(info.RecoveryPath)) a.emitRuntimeEvent("session:recovered", sessionRecoveryEvent{ OriginalPath: info.OriginalPath, RecoveryPath: info.RecoveryPath, @@ -6502,6 +6472,8 @@ type ProjectNode struct { SessionPath string `json:"sessionPath,omitempty"` ProjectColor string `json:"projectColor,omitempty"` Turns int `json:"turns,omitempty"` + TurnsState string `json:"turnsState,omitempty"` + Health string `json:"health,omitempty"` CreatedAt int64 `json:"createdAt,omitempty"` LastActivityAt int64 `json:"lastActivityAt,omitempty"` Open bool `json:"open,omitempty"` @@ -6792,7 +6764,6 @@ func migrateLegacySessionsIntoGlobalTopics(dir string) []string { _ = saveTopicTitleSources(topicTitleRoot, topicSources) } invalidateTopicSessionIndex(dir) - projectSessionCache.invalidateDirs(dir) if !deferred { markTopicMigrationDone(dir) // pass complete with nothing deferred } @@ -6946,9 +6917,6 @@ func repairIndexedSessionTopics(dir string) []string { deferred = true } } - if !deferred { - projectSessionCache.invalidateDirs(dir) - } } if !deferred { markTopicIndexRepairDone(dir) @@ -7516,24 +7484,26 @@ func (a *App) setTabActivityStatus(tabID, status string) bool { } func (a *App) emitProjectTreeChanged() { - projectSessionCache.invalidate() + a.requestSessionCatalogMetadataSync() + for _, target := range a.sessionCatalogTargets() { + a.requestSessionCatalogReconcile(target.Path) + } a.emitProjectTreeChangedEvent() } -// emitProjectTreeChangedForSessionDirs keeps cached listings for unrelated -// workspaces. A session mutation only changes the directory containing that -// transcript, so invalidating every known project turns one archive into an -// O(all sessions) rescan for heavy users. +// emitProjectTreeChangedForSessionDirs schedules only the affected catalog +// directories. It never scans synchronously on the mutation or UI goroutine. func (a *App) emitProjectTreeChangedForSessionDirs(dirs ...string) { - if !projectSessionCache.invalidateDirs(dirs...) { - projectSessionCache.invalidate() + for _, dir := range dirs { + a.requestSessionCatalogReconcile(dir) } a.emitProjectTreeChangedEvent() } // emitProjectTreeMetadataChanged refreshes ordering, titles, pins, and runtime -// status without discarding session listings whose on-disk data did not move. +// status without walking session storage. func (a *App) emitProjectTreeMetadataChanged() { + a.requestSessionCatalogMetadataSync() a.emitProjectTreeChangedEvent() } @@ -7727,324 +7697,6 @@ func topicHiddenAsRecoveryOnly(summary topicSummary, pinned bool, runtimeSession return true } -var listProjectTreeMu sync.Mutex - -func (a *App) ListProjectTree() []ProjectNode { - listProjectTreeMu.Lock() - defer listProjectTreeMu.Unlock() - - knownDirs := a.knownSessionDirs() - for _, dir := range knownDirs { - migrateLegacySessionsIntoGlobalTopics(dir) - } - f := loadProjectsFile() - // Render-side tombstone guard: a deleted topic whose title survived a racy - // whole-map save would otherwise reappear via the orderedTopicIDs title-map - // fallback. The tombstone is authoritative until an intentional - // single-topic write (create/restore/tab indexing) clears it. - deletedTopicSet := make(map[string]bool, len(f.DeletedTopics)) - for _, id := range f.DeletedTopics { - deletedTopicSet[id] = true - } - out := []ProjectNode{} - topicSummaries := map[string]topicSummary{} - sessionInfos := map[string]agent.SessionInfo{} - sessionTitles := map[string]string{} - - // Read session listings from all known directories concurrently, since - // each dir is independent I/O. With N workspaces × dozens of sessions, - // sequential reads add up to seconds of wall time on cold start. - type sessionDirLoadResult struct { - dir string - infos []agent.SessionInfo - titles map[string]string - ok bool - } - results := make(chan sessionDirLoadResult, len(knownDirs)) - pendingLoads := 0 - for _, dir := range knownDirs { - infos, titles, ok := projectSessionCache.get(dir) - if ok { - mergeSessionInfos(dir, infos, titles, sessionInfos, sessionTitles, topicSummaries) - continue - } - pendingLoads++ - // capture - cacheToken := projectSessionCache.versionToken(dir) - go func() { - result := sessionDirLoadResult{dir: dir} - defer func() { - if recover() != nil { - result.ok = false - } - results <- result - }() - - // Sidecar-backed listing: ListSessions reads turn count + preview from - // each session's .meta sidecar, so even large directories list in a few - // milliseconds without decoding any .jsonl body. The in-memory - // projectSessionCache still elides repeat listings within a session. - infos, err := agent.ListSessions(dir) - if err != nil { - return - } - titles := loadSessionTitles(dir) - projectSessionCache.put(dir, infos, titles, cacheToken) - result.infos = infos - result.titles = titles - result.ok = true - }() - } - if pendingLoads > 0 { - timer := time.NewTimer(5 * time.Second) - for received := 0; received < pendingLoads; { - select { - case result := <-results: - received++ - if result.ok { - mergeSessionInfos(result.dir, result.infos, result.titles, sessionInfos, sessionTitles, topicSummaries) - } - case <-timer.C: - received = pendingLoads - } - } - timer.Stop() - } - - runtimeSessionsByTopic := map[string][]runtimeSessionStatus{} - a.mu.RLock() - seenRuntimePaths := map[string]bool{} - addRuntimeSession := func(tab *WorkspaceTab, open bool) { - if tab == nil || strings.TrimSpace(tab.TopicID) == "" { - return - } - sessionPath := sessionRuntimeKey(tab.currentSessionPath()) - if sessionPath == "" || seenRuntimePaths[sessionPath] { - return - } - seenRuntimePaths[sessionPath] = true - info := sessionInfos[sessionPath] - recovered := sessionInfoIsAutomaticRecovery(info) || isAutomaticRecoverySessionPath(sessionPath) - label := runtimeSessionTreeLabel(tab, info, sessionTitles[sessionPath]) - titleSource := tab.topicTitleSource - if strings.TrimSpace(sessionTitles[sessionPath]) != "" { - titleSource = topicTitleSourceManual - } - status := activityStatusForTab(tab) - runtimeStatus := control.RuntimeStatus{} - if tab.Ctrl != nil { - runtimeStatus = tab.Ctrl.RuntimeStatus() - } - running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0 - runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)] = append(runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)], runtimeSessionStatus{ - sessionPath: sessionPath, - label: label, - titleSource: titleSource, - turns: info.Turns, - createdAt: unixMilliOrZero(info.CreatedAt), - lastActivityAt: unixMilliOrZero(info.LastActivityAt), - open: open, - running: running, - status: status, - recovered: recovered, - recoveryReason: info.RecoveryReason, - recoveryDigest: info.RecoveryDigest, - recoveryParentID: string(info.ParentID), - }) - } - for _, tab := range a.tabs { - addRuntimeSession(tab, true) - } - for _, tab := range a.detachedSessions { - addRuntimeSession(tab, false) - } - a.mu.RUnlock() - for key := range runtimeSessionsByTopic { - sort.Slice(runtimeSessionsByTopic[key], func(i, j int) bool { - left := runtimeSessionsByTopic[key][i] - right := runtimeSessionsByTopic[key][j] - if left.lastActivityAt != right.lastActivityAt { - return left.lastActivityAt > right.lastActivityAt - } - return left.sessionPath < right.sessionPath - }) - } - topicRuntimeStatus := func(key string) (open, running bool, status string) { - sessions := runtimeSessionsByTopic[key] - if len(sessions) != 1 { - return false, false, "" - } - session := sessions[0] - return session.open, session.running, session.status - } - runtimeSessionNodes := func(scope, workspaceRoot, topicID, projectColor string) []ProjectNode { - key := topicSummaryKey(scope, workspaceRoot, topicID) - sessions := runtimeSessionsByTopic[key] - if len(sessions) <= 1 { - return nil - } - nodes := make([]ProjectNode, 0, len(sessions)) - for _, session := range sessions { - kind := "session" - if scope == "global" { - kind = "global_session" - } - nodes = append(nodes, ProjectNode{ - Key: projectSessionNodeKey(scope, session.sessionPath), - Kind: kind, - Label: a.localizedTopicTitle(session.label, session.titleSource), - Root: workspaceRoot, - TopicID: topicID, - SessionPath: session.sessionPath, - ProjectColor: projectColor, - Turns: session.turns, - CreatedAt: session.createdAt, - LastActivityAt: session.lastActivityAt, - Open: session.open, - Running: session.running, - Status: session.status, - Recovered: session.recovered, - RecoveryReason: session.recoveryReason, - RecoveryDigest: session.recoveryDigest, - RecoveryParentID: session.recoveryParentID, - }) - } - return nodes - } - - // Global section. - globalTitleMap := loadTopicTitles("") - globalTitleSources := loadTopicTitleSources("") - globalCreatedMap := loadTopicCreatedAts("") - if len(globalTitleMap) > 0 || len(f.Projects) == 0 { - globalTitle := strings.TrimSpace(f.GlobalTitle) - if globalTitle == "" { - globalTitle = "Global" - } - globalColor := normalizeProjectColor(f.GlobalColor) - globalTopicIDs := pinnedTopicIDs(orderedTopicIDs(f.GlobalTopics, globalTitleMap), f.GlobalPinnedTopics) - children := make([]ProjectNode, 0, len(globalTopicIDs)) - for _, id := range globalTopicIDs { - if deletedTopicSet[id] { - continue - } - title := a.localizedTopicTitle(globalTitleMap[id], globalTitleSources[id]) - summaryKey := topicSummaryKey("global", "", id) - summary := topicSummaries[summaryKey] - open, running, status := topicRuntimeStatus(summaryKey) - pinned := containsDesktopString(f.GlobalPinnedTopics, id) - if topicHiddenAsRecoveryOnly(summary, pinned, runtimeSessionsByTopic[summaryKey]) { - continue - } - children = append(children, ProjectNode{ - Key: "global_topic_" + id, - Kind: "global_topic", - Label: title, - TopicID: id, - ProjectColor: globalColor, - Turns: summary.displayTurns(), - CreatedAt: topicCreatedAtForTree(globalCreatedMap, id), - LastActivityAt: summary.lastActivityAt, - Open: open, - Running: running, - Status: status, - Pinned: pinned, - Children: runtimeSessionNodes("global", "", id, globalColor), - }) - } - out = append(out, ProjectNode{ - Key: "global_folder", - Kind: "global_folder", - Label: globalTitle, - Root: globalWorkspaceRoot(), - ProjectColor: globalColor, - Children: children, - }) - } - - // Project sections. - type projectTopics struct { - project desktopProject - titles map[string]string - sources map[string]string - createdAts map[string]int64 - } - projectTopicResults := make([]projectTopics, len(f.Projects)) - var topicLoadWg sync.WaitGroup - for i, p := range f.Projects { - topicLoadWg.Go(func() { - projectTopicResults[i] = projectTopics{ - project: p, - titles: loadTopicTitles(p.Root), - sources: loadTopicTitleSources(p.Root), - createdAts: loadTopicCreatedAts(p.Root), - } - }) - } - topicLoadWg.Wait() - for _, loaded := range projectTopicResults { - p := loaded.project - title := p.Title - if title == "" { - title = workspaceName(p.Root) - } - node := ProjectNode{ - Key: "project_" + p.Root, - Kind: "project", - Root: p.Root, - Pinned: containsDesktopString(f.PinnedProjects, p.Root), - IsolatedWorktree: worktree.IsManagedPath(p.Root, config.DeliveryWorktreeDir()), - } - - // Gather topics: explicit topic list + all known topic titles. - titleMap := loaded.titles - titleSources := loaded.sources - createdMap := loaded.createdAts - topicIDs := pinnedTopicIDs(orderedTopicIDs(p.Topics, titleMap), p.PinnedTopics) - - children := make([]ProjectNode, 0, len(topicIDs)) - for _, tid := range topicIDs { - if deletedTopicSet[tid] { - continue - } - topicTitle := strings.TrimSpace(titleMap[tid]) - if topicTitle == "" { - topicTitle = defaultTopicTitle - } - topicTitle = a.localizedTopicTitle(topicTitle, titleSources[tid]) - summaryKey := topicSummaryKey("project", p.Root, tid) - summary := topicSummaries[summaryKey] - open, running, status := topicRuntimeStatus(summaryKey) - pinned := containsDesktopString(p.PinnedTopics, tid) - if topicHiddenAsRecoveryOnly(summary, pinned, runtimeSessionsByTopic[summaryKey]) { - continue - } - children = append(children, ProjectNode{ - Key: "topic_" + tid, - Kind: "topic", - Label: topicTitle, - Root: p.Root, - TopicID: tid, - ProjectColor: p.Color, - Turns: summary.displayTurns(), - CreatedAt: topicCreatedAtForTree(createdMap, tid), - LastActivityAt: summary.lastActivityAt, - Open: open, - Running: running, - Status: status, - Pinned: pinned, - Children: runtimeSessionNodes("project", p.Root, tid, p.Color), - }) - } - node.Label = title - node.ProjectColor = p.Color - node.Children = children - out = append(out, node) - } - - return applyPinnedProjectOrder(applyProjectTreeOrder(out, f.SidebarOrder), f.PinnedProjects) -} - func topicSummaryKey(scope, workspaceRoot, topicID string) string { if scope == "global" { return "global::" + topicID @@ -8680,6 +8332,29 @@ func pinnedTabSessionPath(dir, sessionPath string) (string, bool) { return path, true } +func pinnedTabSessionPathForBuild(scope, workspaceRoot, targetDir, sessionPath string) (string, bool) { + if path, ok := pinnedTabSessionPath(targetDir, sessionPath); ok { + return path, true + } + // Before per-project storage, desktop tabs persisted exact paths under the + // global session directory. Accept only that owned legacy root, and require + // project ownership metadata before routing a project tab through it. + legacyDir := config.SessionDir() + path, ok := pinnedTabSessionPath(legacyDir, sessionPath) + if !ok { + return "", false + } + meta, hasMeta, err := agent.LoadBranchMeta(path) + if strings.TrimSpace(scope) == "project" { + if err != nil || !hasMeta || meta.Scope != "project" || !sameProjectRoot(meta.WorkspaceRoot, workspaceRoot) { + return "", false + } + } else if err == nil && hasMeta && meta.Scope == "project" { + return "", false + } + return path, true +} + // saveTabSessionMeta persists the tab's scope/topic/mode fields into the // session's branch-meta sidecar at path. Tab fields are snapshotted under a.mu // (controller reads happen off-lock) so a concurrent tab mutation can't tear @@ -9125,152 +8800,6 @@ type topicSessionDirIndex struct { byTopic map[string][]topicSessionMatch } -// sessionListCache caches ListSessions results per directory so that -// ListProjectTree (called on every sidebar render) does not re-read every -// session dir from disk. Session mutations invalidate only their own -// directories; the global generation remains a correctness fallback for -// changes whose affected directories are not known. -type sessionListCacheEntry struct { - infos []agent.SessionInfo - titles map[string]string - cachedAt time.Time -} - -// Scoped invalidation handles in-process mutations immediately. The TTL is the -// low-frequency reconciliation path for CLI, older Desktop, and external -// process writes that cannot emit an event into this process. -const sessionListCacheTTL = 30 * time.Second - -type sessionListCacheToken struct { - globalVersion uint64 - dirVersion uint64 -} - -type sessionListCache struct { - mu sync.Mutex - byDir map[string]sessionListCacheEntry - dirVersions map[string]uint64 - nextDirVersion uint64 - version atomic.Uint64 -} - -func (c *sessionListCache) get(dir string) ([]agent.SessionInfo, map[string]string, bool) { - dir = sessionListCacheDirKey(dir) - c.mu.Lock() - defer c.mu.Unlock() - e, ok := c.byDir[dir] - if !ok { - return nil, nil, false - } - if e.cachedAt.IsZero() || time.Since(e.cachedAt) >= sessionListCacheTTL { - delete(c.byDir, dir) - return nil, nil, false - } - return e.infos, e.titles, true -} - -func (c *sessionListCache) versionToken(dir string) sessionListCacheToken { - dir = sessionListCacheDirKey(dir) - c.mu.Lock() - defer c.mu.Unlock() - if c.dirVersions == nil { - c.dirVersions = map[string]uint64{} - } - dirVersion := c.dirVersions[dir] - if dirVersion == 0 { - c.nextDirVersion++ - dirVersion = c.nextDirVersion - c.dirVersions[dir] = dirVersion - } - return sessionListCacheToken{ - globalVersion: c.version.Load(), - dirVersion: dirVersion, - } -} - -func (c *sessionListCache) put(dir string, infos []agent.SessionInfo, titles map[string]string, token sessionListCacheToken) { - dir = sessionListCacheDirKey(dir) - if c.version.Load() != token.globalVersion { - return - } - c.mu.Lock() - defer c.mu.Unlock() - if c.version.Load() != token.globalVersion || c.dirVersions[dir] != token.dirVersion { - return - } - if c.byDir == nil { - c.byDir = map[string]sessionListCacheEntry{} - } - c.byDir[dir] = sessionListCacheEntry{infos: infos, titles: titles, cachedAt: time.Now()} -} - -func (c *sessionListCache) invalidateDirs(dirs ...string) bool { - keys := make(map[string]struct{}, len(dirs)) - for _, dir := range dirs { - if key := sessionListCacheDirKey(dir); key != "" { - keys[key] = struct{}{} - } - } - if len(keys) == 0 { - return false - } - c.mu.Lock() - defer c.mu.Unlock() - if c.dirVersions == nil { - c.dirVersions = map[string]uint64{} - } - for key := range keys { - c.nextDirVersion++ - c.dirVersions[key] = c.nextDirVersion - delete(c.byDir, key) - } - return true -} - -// forgetDirs drops directories that are no longer part of the project -// lifecycle. Tokens are never reused, so an in-flight scan from before the -// removal cannot repopulate the cache after the entry is forgotten or re-added. -func (c *sessionListCache) forgetDirs(dirs ...string) bool { - keys := make(map[string]struct{}, len(dirs)) - for _, dir := range dirs { - if key := sessionListCacheDirKey(dir); key != "" { - keys[key] = struct{}{} - } - } - if len(keys) == 0 { - return false - } - c.mu.Lock() - defer c.mu.Unlock() - for key := range keys { - delete(c.byDir, key) - delete(c.dirVersions, key) - } - return true -} - -func (c *sessionListCache) invalidate() { - c.mu.Lock() - c.version.Add(1) - c.byDir = map[string]sessionListCacheEntry{} - c.dirVersions = map[string]uint64{} - c.mu.Unlock() -} - -func sessionListCacheDirKey(dir string) string { - return projectRootKey(dir) -} - -func sessionListCacheDirForPath(path string) string { - path = strings.TrimSpace(path) - if path == "" { - return "" - } - return filepath.Dir(path) -} - -var projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}} - // mergeSessionInfos merges one directory's session listing into the maps used by // ListProjectTree. The result collection loop calls it serially. func mergeSessionInfos(dir string, infos []agent.SessionInfo, titles map[string]string, sessionInfos map[string]agent.SessionInfo, sessionTitles map[string]string, topicSummaries map[string]topicSummary) { diff --git a/desktop/tabs_topic_test.go b/desktop/tabs_topic_test.go index 9b34781544..68e6204b5b 100644 --- a/desktop/tabs_topic_test.go +++ b/desktop/tabs_topic_test.go @@ -55,6 +55,47 @@ func waitForTabReady(t *testing.T, app *App, tabID string) *WorkspaceTab { return nil } +func waitForCatalogTopic(t *testing.T, app *App, scope, workspaceRoot, topicID string) []ProjectNode { + t.Helper() + app.startSessionCatalog(false) + t.Cleanup(func() { app.stopSessionCatalog(time.Second) }) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + nodes := app.ListProjectTree() + for _, folder := range nodes { + if scope == "project" && (!sameProjectRoot(folder.Root, workspaceRoot) || folder.Kind != "project") { + continue + } + if scope != "project" && folder.Kind != "global_folder" { + continue + } + for _, topic := range folder.Children { + if topic.TopicID == topicID { + return nodes + } + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("catalog topic %q did not become visible", topicID) + return nil +} + +func waitForCatalogTreeCondition(t *testing.T, app *App, description string, matches func([]ProjectNode) bool) []ProjectNode { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + var nodes []ProjectNode + for time.Now().Before(deadline) { + nodes = app.ListProjectTree() + if matches(nodes) { + return nodes + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("catalog did not reach %s: %#v", description, nodes) + return nil +} + func writeTopicSession(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot string) string { t.Helper() path := filepath.Join(dir, name) @@ -126,256 +167,6 @@ func writeLegacyEventSession(t *testing.T, dir, name, prompt, reply string, modT return path } -func TestSessionListCacheRefillsAfterInvalidate(t *testing.T) { - cache := &sessionListCache{byDir: map[string]sessionListCacheEntry{}} - dir := t.TempDir() - first := []agent.SessionInfo{{Path: filepath.Join(dir, "first.jsonl")}} - second := []agent.SessionInfo{{Path: filepath.Join(dir, "second.jsonl")}} - - token := cache.versionToken(dir) - cache.put(dir, first, map[string]string{"first.jsonl": "First"}, token) - if infos, titles, ok := cache.get(dir); !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "first.jsonl" || titles["first.jsonl"] != "First" { - t.Fatalf("initial cache entry = %+v, %+v, %v", infos, titles, ok) - } - - cache.invalidate() - if _, _, ok := cache.get(dir); ok { - t.Fatalf("cache entry survived invalidate") - } - cache.put(dir, first, map[string]string{"first.jsonl": "stale"}, token) - if _, _, ok := cache.get(dir); ok { - t.Fatalf("stale token repopulated cache after invalidate") - } - - token = cache.versionToken(dir) - cache.put(dir, second, map[string]string{"second.jsonl": "Second"}, token) - if infos, titles, ok := cache.get(dir); !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "second.jsonl" || titles["second.jsonl"] != "Second" { - t.Fatalf("refilled cache entry = %+v, %+v, %v", infos, titles, ok) - } -} - -func TestSessionListCacheInvalidatesOnlyChangedDirectory(t *testing.T) { - cache := &sessionListCache{byDir: map[string]sessionListCacheEntry{}} - changedDir := filepath.Join(t.TempDir(), "changed") - untouchedDir := filepath.Join(t.TempDir(), "untouched") - changedToken := cache.versionToken(changedDir) - untouchedToken := cache.versionToken(untouchedDir) - cache.put(changedDir, []agent.SessionInfo{{Path: filepath.Join(changedDir, "old.jsonl")}}, nil, changedToken) - cache.put(untouchedDir, []agent.SessionInfo{{Path: filepath.Join(untouchedDir, "keep.jsonl")}}, nil, untouchedToken) - - if !cache.invalidateDirs(changedDir) { - t.Fatal("invalidateDirs reported no changed directory") - } - if _, _, ok := cache.get(changedDir); ok { - t.Fatal("changed directory survived scoped invalidation") - } - if infos, _, ok := cache.get(untouchedDir); !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "keep.jsonl" { - t.Fatalf("unrelated directory was invalidated: %+v, %v", infos, ok) - } - - cache.put(changedDir, []agent.SessionInfo{{Path: filepath.Join(changedDir, "stale.jsonl")}}, nil, changedToken) - if _, _, ok := cache.get(changedDir); ok { - t.Fatal("stale directory token repopulated cache after scoped invalidation") - } - - equivalentDir := filepath.Join(untouchedDir, ".") - if !cache.invalidateDirs(equivalentDir) { - t.Fatal("invalidateDirs rejected an equivalent cleaned directory") - } - if _, _, ok := cache.get(untouchedDir); ok { - t.Fatal("equivalent directory spelling did not invalidate the absolute cache key") - } - if got := sessionListCacheDirForPath(""); got != "" { - t.Fatalf("empty session path resolved to cache directory %q", got) - } -} - -func TestSessionListCacheExpiresForExternalProcessReconciliation(t *testing.T) { - cache := &sessionListCache{byDir: map[string]sessionListCacheEntry{}} - dir := t.TempDir() - token := cache.versionToken(dir) - cache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "old.jsonl")}}, nil, token) - - key := sessionListCacheDirKey(dir) - cache.mu.Lock() - entry := cache.byDir[key] - entry.cachedAt = time.Now().Add(-sessionListCacheTTL) - cache.byDir[key] = entry - cache.mu.Unlock() - - if _, _, ok := cache.get(dir); ok { - t.Fatal("expired directory listing remained cached") - } - if _, exists := cache.byDir[key]; exists { - t.Fatal("expired directory listing was not removed") - } -} - -func TestProjectTreeMetadataChangePreservesSessionListings(t *testing.T) { - oldProjectCache := projectSessionCache - projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}} - t.Cleanup(func() { - projectSessionCache = oldProjectCache - }) - - dir := t.TempDir() - projectSessionCache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "keep.jsonl")}}, nil, projectSessionCache.versionToken(dir)) - emitted := 0 - app := NewApp() - app.projectTreeChangedHook = func() { emitted++ } - app.emitProjectTreeMetadataChanged() - - if _, _, ok := projectSessionCache.get(dir); !ok { - t.Fatal("metadata-only project tree change invalidated session listing") - } - if emitted != 1 { - t.Fatalf("project tree hook calls = %d, want 1", emitted) - } -} - -func TestSessionListCacheForgetRejectsInFlightFillAndReadd(t *testing.T) { - cache := &sessionListCache{byDir: map[string]sessionListCacheEntry{}} - dir := t.TempDir() - staleToken := cache.versionToken(dir) - cache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "old.jsonl")}}, nil, staleToken) - - if !cache.forgetDirs(dir) { - t.Fatal("forgetDirs reported no removed directory") - } - if _, _, ok := cache.get(dir); ok { - t.Fatal("forgotten directory remained cached") - } - cache.mu.Lock() - _, versionRetained := cache.dirVersions[sessionListCacheDirKey(dir)] - cache.mu.Unlock() - if versionRetained { - t.Fatal("forgotten directory retained lifecycle metadata") - } - - cache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "stale.jsonl")}}, nil, staleToken) - if _, _, ok := cache.get(dir); ok { - t.Fatal("in-flight pre-removal fill repopulated forgotten directory") - } - - readdToken := cache.versionToken(dir) - if readdToken.dirVersion == staleToken.dirVersion { - t.Fatal("re-added directory reused its pre-removal token") - } - cache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "fresh.jsonl")}}, nil, readdToken) - infos, _, ok := cache.get(dir) - if !ok || len(infos) != 1 || filepath.Base(infos[0].Path) != "fresh.jsonl" { - t.Fatalf("re-added directory did not accept fresh listing: %+v, %v", infos, ok) - } -} - -func TestRemoveWorkspaceForgetsProjectSessionCache(t *testing.T) { - isolateDesktopUserDirs(t) - oldProjectCache := projectSessionCache - projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}} - t.Cleanup(func() { projectSessionCache = oldProjectCache }) - - projectRoot := t.TempDir() - if err := addProject(projectRoot, "Cached Project"); err != nil { - t.Fatalf("add project: %v", err) - } - dir := desktopSessionDir(projectRoot) - staleToken := projectSessionCache.versionToken(dir) - projectSessionCache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "old.jsonl")}}, nil, staleToken) - - app := NewApp() - if err := app.RemoveWorkspace(projectRoot); err != nil { - t.Fatalf("RemoveWorkspace: %v", err) - } - if _, _, ok := projectSessionCache.get(dir); ok { - t.Fatal("removed workspace session listing remained cached") - } - - projectSessionCache.put(dir, []agent.SessionInfo{{Path: filepath.Join(dir, "stale.jsonl")}}, nil, staleToken) - if _, _, ok := projectSessionCache.get(dir); ok { - t.Fatal("removed workspace accepted an in-flight stale cache fill") - } -} - -func TestRenameSessionInvalidatesProjectTreeCache(t *testing.T) { - isolateDesktopUserDirs(t) - oldProjectCache := projectSessionCache - projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}} - t.Cleanup(func() { - projectSessionCache = oldProjectCache - }) - - dir := t.TempDir() - otherDir := t.TempDir() - sessionPath := filepath.Join(dir, "rename-me.jsonl") - if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil { - t.Fatalf("write session: %v", err) - } - ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test"}) - defer ctrl.Close() - app := NewApp() - app.setTestCtrl(ctrl, "") - - token := projectSessionCache.versionToken(dir) - projectSessionCache.put(dir, []agent.SessionInfo{{Path: sessionPath}}, map[string]string{"rename-me.jsonl": "old"}, token) - projectSessionCache.put(otherDir, []agent.SessionInfo{{Path: filepath.Join(otherDir, "keep.jsonl")}}, nil, projectSessionCache.versionToken(otherDir)) - if _, _, ok := projectSessionCache.get(dir); !ok { - t.Fatalf("expected primed project tree cache") - } - if err := app.RenameSession(sessionPath, "new title"); err != nil { - t.Fatalf("RenameSession: %v", err) - } - if _, _, ok := projectSessionCache.get(dir); ok { - t.Fatalf("RenameSession should invalidate project tree cache") - } - if _, _, ok := projectSessionCache.get(otherDir); !ok { - t.Fatalf("RenameSession invalidated an unrelated session directory") - } -} - -func TestArchiveAndRestoreSessionInvalidateOnlyOwningDirectory(t *testing.T) { - isolateDesktopUserDirs(t) - oldProjectCache := projectSessionCache - projectSessionCache = &sessionListCache{byDir: map[string]sessionListCacheEntry{}} - t.Cleanup(func() { - projectSessionCache = oldProjectCache - }) - - dir := config.SessionDir() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir sessions: %v", err) - } - path := writeLegacySession(t, dir, "scoped-cache.jsonl", "cache scope", time.Now()) - otherDir := t.TempDir() - prime := func(cacheDir, sessionPath string) { - projectSessionCache.put(cacheDir, []agent.SessionInfo{{Path: sessionPath}}, nil, projectSessionCache.versionToken(cacheDir)) - } - prime(dir, path) - prime(otherDir, filepath.Join(otherDir, "keep.jsonl")) - - app := NewApp() - if err := app.DeleteSession(path); err != nil { - t.Fatalf("DeleteSession: %v", err) - } - if _, _, ok := projectSessionCache.get(dir); ok { - t.Fatal("DeleteSession kept the owning directory cached") - } - if _, _, ok := projectSessionCache.get(otherDir); !ok { - t.Fatal("DeleteSession invalidated an unrelated directory") - } - - trashPath := filepath.Join(dir, sessionTrashDir, "scoped-cache.jsonl", "scoped-cache.jsonl") - prime(dir, path) - if err := app.RestoreSession(trashPath); err != nil { - t.Fatalf("RestoreSession: %v", err) - } - if _, _, ok := projectSessionCache.get(dir); ok { - t.Fatal("RestoreSession kept the owning directory cached") - } - if _, _, ok := projectSessionCache.get(otherDir); !ok { - t.Fatal("RestoreSession invalidated an unrelated directory") - } -} - func TestTopicMetadataUpdatesPreserveExistingEntriesWhenTimedReadSlotsFull(t *testing.T) { isolateDesktopUserDirs(t) @@ -911,7 +702,8 @@ func TestLegacySessionsMigrateIntoGlobalTopics(t *testing.T) { older := writeLegacySession(t, dir, "older.jsonl", "older imported prompt", time.Now().Add(-2*time.Hour)) newer := writeLegacySession(t, dir, "newer.jsonl", "newer imported prompt", time.Now().Add(-time.Hour)) - nodes := NewApp().ListProjectTree() + app := NewApp() + nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(newer)) if len(nodes) != 1 || nodes[0].Kind != "global_folder" { t.Fatalf("project tree = %#v, want global folder", nodes) } @@ -933,7 +725,7 @@ func TestLegacySessionsMigrateIntoGlobalTopics(t *testing.T) { t.Fatalf("migrated meta = %+v", meta) } - nodes = NewApp().ListProjectTree() + nodes = app.ListProjectTree() if got := len(nodes[0].Children); got != 2 { t.Fatalf("migration should be idempotent, global topics = %d", got) } @@ -954,7 +746,8 @@ func TestAmbiguousLegacyRecoverySessionsMigrateIntoTopics(t *testing.T) { t.Fatalf("write v1 migration marker: %v", err) } - nodes := NewApp().ListProjectTree() + app := NewApp() + nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(recovery)) if len(nodes) != 1 || nodes[0].Kind != "global_folder" { t.Fatalf("project tree = %#v, want global folder", nodes) } @@ -985,7 +778,8 @@ func TestUnmodifiedRecoveryCopyDoesNotMigrateIntoTopics(t *testing.T) { parent, recovery, branchMsgs := forkDesktopRecoveryBranch(t, dir, "normal") coverDesktopRecoveryParent(t, parent, branchMsgs) - nodes := NewApp().ListProjectTree() + app := NewApp() + nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(parent)) if len(nodes) != 1 || len(nodes[0].Children) != 1 { t.Fatalf("project tree = %#v, want only the covering parent topic", nodes) } @@ -1007,7 +801,7 @@ func TestCoveredRecoveryCopyBecomesVisibleAfterMigratedParentDeletion(t *testing coverDesktopRecoveryParent(t, parent, branchMsgs) app := NewApp() - app.ListProjectTree() + waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(parent)) for _, marker := range []string{topicMigrationMarker, topicIndexRepairMarker} { if _, err := os.Stat(filepath.Join(dir, marker)); err != nil { t.Fatalf("expected %s after migration: %v", marker, err) @@ -1028,7 +822,7 @@ func TestCoveredRecoveryCopyBecomesVisibleAfterMigratedParentDeletion(t *testing } } - nodes := app.ListProjectTree() + nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(recovery)) meta, ok, err := agent.LoadBranchMeta(recovery) if err != nil || !ok { t.Fatalf("load recovery meta after parent deletion: ok=%v err=%v", ok, err) @@ -1127,7 +921,19 @@ func TestProjectTreeKeepsAmbiguousMigratedRecoveryTopicVisible(t *testing.T) { t.Fatalf("write v1 repair marker: %v", err) } - nodes := NewApp().ListProjectTree() + app := NewApp() + _ = waitForCatalogTopic(t, app, "global", "", topicID) + nodes := waitForCatalogTreeCondition(t, app, "a repaired ambiguous recovery topic", func(nodes []ProjectNode) bool { + if len(nodes) == 0 { + return false + } + for _, node := range nodes[0].Children { + if node.TopicID == topicID { + return node.TurnsState == "valid" && node.Turns == 1 + } + } + return false + }) if len(nodes) == 0 { t.Fatal("project tree is empty") } @@ -1150,9 +956,11 @@ func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) { } writeLegacySession(t, dir, "first.jsonl", "first legacy prompt", time.Now().Add(-time.Hour)) - // First render migrates the legacy session and, with nothing deferred, stamps - // the one-shot marker so later renders can skip the scan. - NewApp().ListProjectTree() + // Background catalog reconciliation migrates the legacy session and, with + // nothing deferred, stamps the one-shot marker. Tree reads never do this I/O. + app := NewApp() + firstTopicID := legacySessionTopicID(filepath.Join(dir, "first.jsonl")) + waitForCatalogTopic(t, app, "global", "", firstTopicID) if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err != nil { t.Fatalf("expected migration marker after a complete pass: %v", err) } @@ -1161,7 +969,8 @@ func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) { // gate and gets a fresh migration pass. time.Sleep(10 * time.Millisecond) second := writeLegacySession(t, dir, "second.jsonl", "second legacy prompt", time.Now()) - NewApp().ListProjectTree() + app.requestSessionCatalogReconcile(dir) + waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(second)) meta, ok, err := agent.LoadBranchMeta(second) if err != nil { t.Fatalf("load second meta: %v", err) @@ -1574,7 +1383,8 @@ func TestLegacySessionTopicIDsKeepNormalizedNameCollisionsDistinct(t *testing.T) t.Fatalf("normalized legacy topic IDs collided: %q", dottedTopic) } - nodes := NewApp().ListProjectTree() + app := NewApp() + nodes := waitForCatalogTopic(t, app, "global", "", dottedTopic) if len(nodes) != 1 || nodes[0].Kind != "global_folder" { t.Fatalf("project tree = %#v, want global folder", nodes) } @@ -1590,7 +1400,7 @@ func TestLegacySessionTopicIDsKeepNormalizedNameCollisionsDistinct(t *testing.T) } } -func TestDefaultGlobalTabGetsMigratedTopicID(t *testing.T) { +func TestDefaultGlobalTabDoesNotWaitForLegacyMigration(t *testing.T) { isolateDesktopUserDirs(t) dir := config.SessionDir() @@ -1616,19 +1426,19 @@ func TestDefaultGlobalTabGetsMigratedTopicID(t *testing.T) { defer tab.Ctrl.Close() } - wantTopicID := legacySessionTopicID(sessionPath) - if tab.TopicID != wantTopicID { - t.Fatalf("tab topicID = %q, want %q", tab.TopicID, wantTopicID) + if tab.TopicID != "" { + t.Fatalf("blank tab synchronously adopted legacy topic %q", tab.TopicID) } if tab.Ctrl == nil { t.Fatalf("tab controller was not built") } - if tab.Ctrl.SessionPath() != sessionPath { - t.Fatalf("tab session path = %q, want %q", tab.Ctrl.SessionPath(), sessionPath) + if tab.Ctrl.SessionPath() == sessionPath { + t.Fatalf("blank tab synchronously adopted legacy session %q", sessionPath) } - f := loadTabsFile() - if len(f.Tabs) != 1 || f.Tabs[0].ID != "tab_legacy" || f.Tabs[0].TopicID != wantTopicID { - t.Fatalf("desktop tabs file = %+v, want tab id and migrated topic", f) + wantTopicID := legacySessionTopicID(sessionPath) + nodes := waitForCatalogTopic(t, app, "global", "", wantTopicID) + if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID { + t.Fatalf("legacy history was not eventually indexed: %+v", nodes) } } @@ -2937,7 +2747,7 @@ func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) { topicID := legacySessionTopicID(sessionPath) app := NewApp() - nodes := app.ListProjectTree() + nodes := waitForCatalogTopic(t, app, "global", "", topicID) if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID { t.Fatalf("legacy session should start in Global, got %#v", nodes) } @@ -2958,7 +2768,7 @@ func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) { if got := app.ListTrashedSessions(); len(got) != 0 { t.Fatalf("trash should be empty after restore, got %#v", got) } - nodes = app.ListProjectTree() + nodes = waitForCatalogTopic(t, app, "global", "", topicID) if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID { t.Fatalf("restored global session should reappear in Global, got %#v", nodes) } @@ -2996,7 +2806,7 @@ func TestRestoreProjectTopicSessionReindexesProjectTree(t *testing.T) { if err := app.RestoreSession(trashPath); err != nil { t.Fatalf("restore project session: %v", err) } - nodes := app.ListProjectTree() + nodes := waitForCatalogTopic(t, app, "project", projectRoot, topicID) if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID { t.Fatalf("restored project session should reappear in project tree, got %#v", nodes) } @@ -3024,7 +2834,7 @@ func TestOpenProjectTabResolvesProjectSessionFromLegacyDir(t *testing.T) { sessionPath := writeTopicSessionWithPrompt(t, dir, "legacy-project.jsonl", topicID, topicTitle, projectRoot, "legacy project prompt", time.Now()) app := NewApp() - nodes := app.ListProjectTree() + nodes := waitForCatalogTopic(t, app, "project", projectRoot, topicID) if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID { t.Fatalf("legacy project session should appear in project tree, got %#v", nodes) } @@ -3885,7 +3695,8 @@ func TestProjectTreeMigratesCLISessionFromProjectDir(t *testing.T) { sessionPath := writeLegacySession(t, dir, "cli-project.jsonl", "cli project prompt", time.Now()) wantTopicID := legacySessionTopicID(sessionPath) - nodes := NewApp().ListProjectTree() + app := NewApp() + nodes := waitForCatalogTopic(t, app, "project", projectRoot, wantTopicID) if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID { t.Fatalf("project CLI session should appear in project tree, got %#v; want topic %q", nodes, wantTopicID) } @@ -3905,7 +3716,8 @@ func TestProjectTreeMigratesNewCLISessionAfterProjectDirMarker(t *testing.T) { first := writeLegacySession(t, dir, "first-cli-project.jsonl", "first cli project prompt", time.Now().Add(-time.Hour)) firstTopicID := legacySessionTopicID(first) - nodes := NewApp().ListProjectTree() + app := NewApp() + nodes := waitForCatalogTopic(t, app, "project", projectRoot, firstTopicID) if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != firstTopicID { t.Fatalf("first project CLI session should appear in project tree, got %#v; want topic %q", nodes, firstTopicID) } @@ -3917,7 +3729,12 @@ func TestProjectTreeMigratesNewCLISessionAfterProjectDirMarker(t *testing.T) { second := writeLegacySession(t, dir, "second-cli-project.jsonl", "second cli project prompt", time.Now()) secondTopicID := legacySessionTopicID(second) - nodes = NewApp().ListProjectTree() + app.requestSessionCatalogReconcile(dir) + nodes = waitForCatalogTreeCondition(t, app, "a reconciled newest project CLI session", func(nodes []ProjectNode) bool { + return len(nodes) == 1 && nodes[0].Kind == "project" && len(nodes[0].Children) == 2 && + nodes[0].Children[0].TopicID == secondTopicID && nodes[0].Children[0].LastActivityAt > 0 && + nodes[0].Children[1].TopicID == firstTopicID + }) if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 2 { t.Fatalf("second project CLI session should trigger re-scan, got %#v", nodes) } @@ -3945,7 +3762,8 @@ func TestProjectTreeMigratesCLISessionFromGlobalWorkspaceDir(t *testing.T) { } wantTopicID := legacySessionTopicID(sessionPath) - nodes := NewApp().ListProjectTree() + app := NewApp() + nodes := waitForCatalogTopic(t, app, "global", "", wantTopicID) if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID { t.Fatalf("global workspace CLI session should appear in Global, got %#v; want topic %q", nodes, wantTopicID) } diff --git a/desktop/task_monitor_test.go b/desktop/task_monitor_test.go index d047e8602c..c2e252054f 100644 --- a/desktop/task_monitor_test.go +++ b/desktop/task_monitor_test.go @@ -227,7 +227,7 @@ func TestListSessionsForTabKeepsSourceDirectoryAfterActiveTabSwitch(t *testing.T } sessions := app.ListSessionsForTab("tab-a") - if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].Preview != "workspace A" { + if len(sessions) != 1 || sessions[0].Path != pathA || sessions[0].TurnsState != "unknown" { t.Fatalf("ListSessionsForTab(tab-a) = %+v", sessions) } } diff --git a/desktop/topic_archive.go b/desktop/topic_archive.go index 8741e950d5..accc676963 100644 --- a/desktop/topic_archive.go +++ b/desktop/topic_archive.go @@ -151,6 +151,7 @@ func (a *App) commitTopicArchive(topicID string, trace *topicArchiveTrace) (fall for i, target := range targets { destroys := destroyBatches[i] a.closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed, target.dir, target.sessionPath, closedRemoved) + a.removeSessionCatalogPath(target.sessionPath, "topic_archived") if timedOutTargets[i] { guard := ownership.take(target.sessionPath) go delayedDesktopTopicTrash(target.dir, target.sessionPath, target.key, guard, destroys) diff --git a/desktop/tray.go b/desktop/tray.go index dcde840a7d..f6c2b9c6fe 100644 --- a/desktop/tray.go +++ b/desktop/tray.go @@ -1,3 +1,5 @@ +//go:build windows || cgo + package main import ( @@ -119,43 +121,3 @@ func (a *App) updateTrayLocale(locale string) { quitItem.SetTitle(labels.quitTitle) quitItem.SetTooltip(labels.quitTooltip) } - -func (a *App) trayLocale() string { - cfg, _, err := a.loadDesktopUserConfigForView() - if err != nil { - return "" - } - return cfg.DesktopLanguage() -} - -func (a *App) showFromTray() { - a.showMainWindowFrom("tray") -} - -func (a *App) quitFromTray() { - a.quitApp() -} - -type trayLabels struct { - openTitle string - openTooltip string - quitTitle string - quitTooltip string -} - -func trayMenuLabels(locale string) trayLabels { - if locale == "zh" { - return trayLabels{ - openTitle: "打开", - openTooltip: "打开 Reasonix 窗口", - quitTitle: "退出", - quitTooltip: "退出 Reasonix", - } - } - return trayLabels{ - openTitle: "Open", - openTooltip: "Open the Reasonix window", - quitTitle: "Quit", - quitTooltip: "Quit Reasonix", - } -} diff --git a/desktop/tray_common.go b/desktop/tray_common.go new file mode 100644 index 0000000000..ddb93bd3ee --- /dev/null +++ b/desktop/tray_common.go @@ -0,0 +1,41 @@ +package main + +func (a *App) trayLocale() string { + cfg, _, err := a.loadDesktopUserConfigForView() + if err != nil { + return "" + } + return cfg.DesktopLanguage() +} + +func (a *App) showFromTray() { + a.showMainWindowFrom("tray") +} + +func (a *App) quitFromTray() { + a.quitApp() +} + +type trayLabels struct { + openTitle string + openTooltip string + quitTitle string + quitTooltip string +} + +func trayMenuLabels(locale string) trayLabels { + if locale == "zh" { + return trayLabels{ + openTitle: "打开", + openTooltip: "打开 Reasonix 窗口", + quitTitle: "退出", + quitTooltip: "退出 Reasonix", + } + } + return trayLabels{ + openTitle: "Open", + openTooltip: "Open the Reasonix window", + quitTitle: "Quit", + quitTooltip: "Quit Reasonix", + } +} diff --git a/desktop/tray_loop_external.go b/desktop/tray_loop_external.go index a3fe9b8f2c..cfd0fbf926 100644 --- a/desktop/tray_loop_external.go +++ b/desktop/tray_loop_external.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && cgo package main diff --git a/desktop/tray_nocgo.go b/desktop/tray_nocgo.go new file mode 100644 index 0000000000..91ed723d9d --- /dev/null +++ b/desktop/tray_nocgo.go @@ -0,0 +1,18 @@ +//go:build !windows && !cgo + +package main + +// desktopTray remains part of App's shape in pure-Go builds, but native tray +// implementations require platform UI libraries. Desktop startup already +// treats a missing tray as an optional capability. +type desktopTray struct { + ready chan struct{} +} + +func (a *App) startTray() bool { + return false +} + +func (a *App) stopTray() {} + +func (a *App) updateTrayLocale(string) {} diff --git a/desktop/webkit_compat_linux.go b/desktop/webkit_compat_linux.go index 4d50ab5d5e..bb1e9fcaa3 100644 --- a/desktop/webkit_compat_linux.go +++ b/desktop/webkit_compat_linux.go @@ -1,3 +1,5 @@ +//go:build linux && cgo + // Package main provides the Wails desktop shell around the Reasonix kernel. // // webkit_compat_linux.go applies WebKit2GTK compatibility workarounds around diff --git a/desktop/webkit_compat_other.go b/desktop/webkit_compat_other.go index d324cc389e..f2278b4614 100644 --- a/desktop/webkit_compat_other.go +++ b/desktop/webkit_compat_other.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux || !cgo package main diff --git a/docs/CLI.md b/docs/CLI.md index 82d46cbcbc..9f684d4e9a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -452,6 +452,22 @@ Switching model, effort, or work mode rebuilds the runtime while preserving the active conversation, session-scoped permission overrides, additional directory access, and session ownership. `/reload` uses the same fail-atomic rebuild. +## Session catalog diagnostics + +The desktop session catalog is a disposable SQLite query projection; transcript +JSONL and sidecars remain authoritative. Inspect it read-only or replace only +the projection: + +```sh +reasonix doctor sessions [--json] +reasonix sessions reindex [--json] +reasonix sessions reindex --dir /path/to/sessions --dir /another/path +``` + +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. + ### 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 db199abf62..b484b4d3f7 100644 --- a/docs/CLI.zh-CN.md +++ b/docs/CLI.zh-CN.md @@ -392,6 +392,20 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键 切换模型、effort 或工作模式会重建运行时,同时保留当前对话、会话级权限覆盖、附加目录 访问权限和 session ownership。`/reload` 使用同一套失败原子重建语义。 +## 会话目录索引诊断 + +桌面会话目录索引是可丢弃、可重建的 SQLite 查询投影;JSONL transcript 和 sidecar +始终是权威数据。可只读检查,或仅替换投影: + +```sh +reasonix doctor sessions [--json] +reasonix sessions reindex [--json] +reasonix sessions reindex --dir /path/to/sessions --dir /another/path +``` + +不传 `--dir` 时,reindex 会覆盖全局会话和桌面保存的全部项目。迁移、失败降级和数据安全 +契约见 [Session Catalog and Desktop Startup](./SESSION_CATALOG.md)。 + ### 记忆诊断与恢复 直接运行 `/memory` 会显示全部 project/global active facts,不会隐藏跨 scope 的同名条目。 diff --git a/docs/CONFIG_PATHS.md b/docs/CONFIG_PATHS.md index 0d27c1f7dd..f6258533da 100644 --- a/docs/CONFIG_PATHS.md +++ b/docs/CONFIG_PATHS.md @@ -40,10 +40,15 @@ non-destructively when `/.env` is missing them. | Sessions | `/sessions/` | | Archives | `/archive/` | | Memory | `/memory/` and `/projects/` | +| Disposable session catalog | `/session-catalog/v1.sqlite` | `` defaults to ``. It only differs when `REASONIX_STATE_HOME` is set. +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). + 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 mean `/config.toml`. diff --git a/docs/SESSION_CATALOG.md b/docs/SESSION_CATALOG.md new file mode 100644 index 0000000000..6b4edd164f --- /dev/null +++ b/docs/SESSION_CATALOG.md @@ -0,0 +1,101 @@ +# Session Catalog and Desktop Startup + +Reasonix keeps session transcripts, event logs, metadata sidecars, and +`desktop-projects.json` as the only authoritative session data. The desktop +project tree reads a disposable SQLite projection from +`/session-catalog/v1.sqlite`; deleting that database never deletes +or changes a conversation. + +## Invariants + +- Startup and project-tree requests never decode transcript JSONL, run legacy + migration, or wait for a directory scan. +- A successfully saved transcript is committed before its catalog update. The + non-blocking writer coalesces updates by session path; background + reconciliation repairs updates dropped under queue pressure. +- Missing legacy counts are represented as `unknown`. The session is visible + immediately, then a single repair worker decodes it in the background. +- A missing file is marked degraded on the first scan. It is removed from the + projection only after a second scan and the missing-file grace period. +- Runtime state (`open`, `running`, and live status) comes only from in-memory + controllers and overlays catalog results. It is never persisted to SQLite. +- Catalog, migration, plugin, and MCP work is cancellable and never participates + in the desktop shutdown lock. Shutdown gives pending catalog writes at most + 250 ms. + +## Storage and migration + +`internal/sessioncatalog` uses a version ledger in `schema_migrations`; database +existence is not a migration signal. Local cache files use WAL, +`synchronous=NORMAL`, and a short busy timeout. An unavailable or obviously +remote cache path falls back to an in-memory catalog so storage failures cannot +block the application. + +At open, Reasonix runs an integrity check. A corrupt or unmigratable database is +renamed with a `.corrupt-` suffix and replaced. The replacement is +rebuilt from sidecars and transcripts in the background. The quarantine and +rebuild paths never remove authoritative files. + +The catalog stores only query projections: + +- directory signatures, scan generations, checkpoints, and errors; +- project ordering, title, color, and pin state; +- topic ordering, aggregate counts, activity, recovery, and health state; and +- session path, preview, counts, fingerprints, recovery, and health state. + +Topic pages use a `(pinned, last_activity_at, topic_id)` keyset cursor. The +default page size is 50 and the maximum is 200. Directory reconciliation commits +at most 64 sidecars per batch and persists its checkpoint before yielding. + +## Desktop API + +- `GetProjectTreeSnapshot` returns project shells, catalog state, progress, and + revision without opening a session or sidecar file. +- `ListProjectTopics` performs cursor-paged search and time filtering. +- `GetTopicSummary` resolves one topic for active-turn UI without rebuilding the + tree. +- `GetSessionCatalogStatus` and `RebuildSessionCatalog` expose safe diagnostics + and replacement. +- `project-tree:changed-v2` carries a monotonic revision, affected workspace + roots, and reason. Clients ignore older revisions and refresh only expanded + affected roots. + +`ListProjectTree` remains as a compatibility wrapper over the catalog. It no +longer has a synchronous filesystem fallback. + +## Operations + +Inspect the catalog without creating or changing it: + +```sh +reasonix doctor sessions +reasonix doctor sessions --json +``` + +Replace only the disposable projection and index all saved desktop projects: + +```sh +reasonix sessions reindex +reasonix sessions reindex --json +``` + +Use repeated `--dir PATH` flags to rebuild from an explicit set of directories. +Explicit directories are treated as global scope. Reindexing never edits or +deletes transcript, event, metadata, recovery, archive, or project files. + +## Plugin isolation + +Manifest validation and plugin handshakes are independent from catalog and +project-tree work. An incompatible plugin is reported as +`disabled_incompatible`; the core controller remains usable. A legacy manifest +under Reasonix's managed plugin directory is atomically upgraded with a backup. +Development directories, absolute external roots, and symlinked sources are +never rewritten automatically and include a manual migration hint instead. + +## Release gates + +Preview/canary promotion should track catalog repair backlog, rebuild failures, +page latency, queue pressure, and shutdown duration. Required checks include +legacy/ corrupt fixtures, deterministic lifecycle races, `go test -race`, the +React contract tests, and `CGO_ENABLED=0` builds for supported macOS, Windows, +and Linux architectures. diff --git a/docs/SESSION_CATALOG.zh-CN.md b/docs/SESSION_CATALOG.zh-CN.md new file mode 100644 index 0000000000..e809dd9fe7 --- /dev/null +++ b/docs/SESSION_CATALOG.zh-CN.md @@ -0,0 +1,87 @@ +# 会话索引与桌面启动 + +Reasonix 始终以会话 transcript、event log、metadata sidecar 和 +`desktop-projects.json` 作为唯一权威数据。桌面项目树读取位于 +`<缓存根目录>/session-catalog/v1.sqlite` 的一次性 SQLite 查询投影;删除该数据库 +不会删除或修改任何会话。 + +## 不变量 + +- 启动和项目树请求不会解码 transcript JSONL、执行旧版迁移或等待目录扫描。 +- transcript 成功落盘后才更新 catalog。非阻塞单写者队列按 session path 合并更新; + 后台对账会修复队列拥塞时丢弃的更新。 +- 缺少旧版计数时使用 `unknown` 状态。会话会立即可见,随后由单个 repair worker + 在后台解码修复。 +- 文件首次缺失时只标记为 degraded;只有连续第二次扫描仍缺失且超过宽限期后, + 才会从查询投影移除。 +- 运行时状态(`open`、`running` 和实时状态)只来自内存 controller,并覆盖 + catalog 结果;这些状态永远不会持久化到 SQLite。 +- catalog、迁移、插件和 MCP 工作都可取消,且不参与桌面退出锁。退出最多等待 + catalog 待写入数据 250 ms。 + +## 存储与迁移 + +`internal/sessioncatalog` 使用 `schema_migrations` 版本台账;数据库文件存在不代表 +迁移完成。本地缓存使用 WAL、`synchronous=NORMAL` 和较短的 busy timeout。 +缓存目录不可用或明确位于远程文件系统时,会降级为内存 catalog,避免存储故障 +阻塞应用。 + +打开数据库时,Reasonix 会执行完整性检查。损坏或无法迁移的数据库会被重命名, +附加 `.corrupt-<时间戳>` 后缀并由新数据库替代;随后在后台根据 sidecar 和 +transcript 重建。隔离和重建都不会删除权威文件。 + +catalog 只保存查询投影: + +- 目录签名、扫描代次、检查点和错误; +- 项目排序、标题、颜色和置顶状态; +- topic 排序、聚合计数、活动时间、恢复和健康状态; +- session path、preview、计数、指纹、恢复和健康状态。 + +topic 分页使用 `(pinned, last_activity_at, topic_id)` keyset cursor。默认每页 +50 条,最多 200 条。目录对账每批最多提交 64 个 sidecar,并在让出调度前持久化 +检查点。 + +## 桌面 API + +- `GetProjectTreeSnapshot` 返回项目壳、catalog 状态、进度和 revision,不打开 + session 或 sidecar 文件。 +- `ListProjectTopics` 提供基于 cursor 的分页搜索和时间过滤。 +- `GetTopicSummary` 为 active-turn UI 查询单个 topic,无需重建整棵树。 +- `GetSessionCatalogStatus` 和 `RebuildSessionCatalog` 提供安全诊断和索引替换。 +- `project-tree:changed-v2` 携带单调递增的 revision、受影响 workspace root 和 + 原因。客户端会忽略旧 revision,并只刷新受影响且已展开的项目。 + +`ListProjectTree` 作为基于 catalog 的兼容包装继续保留,不再回退到同步文件系统 +扫描。 + +## 运维 + +只读检查 catalog,不创建或修改它: + +```sh +reasonix doctor sessions +reasonix doctor sessions --json +``` + +只替换一次性查询投影,并索引所有已保存的桌面项目: + +```sh +reasonix sessions reindex +reasonix sessions reindex --json +``` + +可重复传入 `--dir PATH`,从指定目录集合重建;显式目录按 global scope 处理。 +reindex 不会编辑或删除 transcript、event、metadata、recovery、archive 或项目文件。 + +## 插件隔离 + +manifest 校验和插件握手与 catalog、项目树互相独立。不兼容插件会报告为 +`disabled_incompatible`,核心 controller 仍可使用。Reasonix 管理目录中的旧版 +manifest 会在生成备份后原子升级;开发目录、外部绝对路径和软链接源码不会被自动 +改写,而会给出手动迁移提示。 + +## 发布门禁 + +Preview/canary 晋级应观测 catalog 修复积压、重建失败、分页延迟、队列压力和退出 +耗时。必须覆盖旧版与损坏 fixture、确定性的生命周期竞态、`go test -race`、React +契约测试,以及受支持 macOS、Windows 和 Linux 架构的 `CGO_ENABLED=0` 构建。 diff --git a/go.mod b/go.mod index d46998ff5d..e5f729ec3b 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( golang.org/x/term v0.45.0 golang.org/x/text v0.40.0 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.56.0 mvdan.cc/sh/v3 v3.13.1 ) @@ -54,13 +55,21 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-pointer v0.0.1 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.22.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index d813411d96..1afec27f78 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= @@ -59,8 +61,14 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -79,16 +87,22 @@ github.com/larksuite/oapi-sdk-go/v3 v3.9.9 h1:qzVK5U1AuT/n0Z4LqCN2ATU6MlYp2Iipa7 github.com/larksuite/oapi-sdk-go/v3 v3.9.9/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -183,6 +197,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -193,5 +209,33 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/internal/agent/listsessions_sidecar_test.go b/internal/agent/listsessions_sidecar_test.go index a65ad5b502..4f4b4ad730 100644 --- a/internal/agent/listsessions_sidecar_test.go +++ b/internal/agent/listsessions_sidecar_test.go @@ -77,9 +77,39 @@ func TestListSessionsUsesSidecarWithoutDecoding(t *testing.T) { } } -// A legacy session whose sidecar has no recorded turn count must be decoded once -// and then backfilled, so the second listing reads the sidecar. -func TestListSessionsBackfillsLegacySession(t *testing.T) { +func TestListSessionsLeavesLegacyCountsUnknownWithoutDecodingOrWriting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "legacy.jsonl") + if err := os.WriteFile(path, []byte("not valid jsonl\n"), 0o600); err != nil { + t.Fatal(err) + } + updated := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) + if err := SaveBranchMetaPreserveUpdated(path, BranchMeta{ + ID: BranchID(path), CreatedAt: updated, UpdatedAt: updated, Scope: "global", + }); err != nil { + t.Fatal(err) + } + + infos, err := ListSessions(dir) + if err != nil { + t.Fatal(err) + } + if len(infos) != 1 || infos[0].CountsKnown || infos[0].Turns != 0 || !strings.Contains(infos[0].Preview, "being indexed") { + t.Fatalf("legacy listing = %#v", infos) + } + meta, ok, err := LoadBranchMeta(path) + if err != nil || !ok { + t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err) + } + if meta.SchemaVersion != 0 || meta.Turns != 0 || !meta.UpdatedAt.Equal(updated) { + t.Fatalf("listing mutated legacy sidecar: %+v", meta) + } +} + +// A legacy session whose sidecar has no recorded turn count remains visible +// without a synchronous transcript decode. The catalog repair worker owns the +// eventual backfill. +func TestListSessionsLeavesLegacySessionForBackgroundRepair(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl") writeSessionFile(t, path, []provider.Message{ @@ -99,18 +129,17 @@ func TestListSessionsBackfillsLegacySession(t *testing.T) { if err != nil { t.Fatalf("ListSessions: %v", err) } - if len(infos) != 1 || infos[0].Turns != 2 { - t.Fatalf("expected 1 session with 2 turns, got %+v", infos) + if len(infos) != 1 || infos[0].Turns != 0 || infos[0].CountsKnown { + t.Fatalf("expected one unknown-count session, got %+v", infos) } - // Backfill must have written the counts into the sidecar without bumping - // activity time (ordering must stay stable). + // Listing must not mutate the sidecar or bump activity time. meta, ok, err := LoadBranchMeta(path) if err != nil || !ok { t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err) } - if meta.Turns != 2 || meta.Preview != "legacy question" { - t.Fatalf("backfill missing: turns=%d preview=%q", meta.Turns, meta.Preview) + if meta.Turns != 0 || meta.Preview != "" || meta.SchemaVersion != 0 { + t.Fatalf("listing mutated counts: %+v", meta) } if !meta.UpdatedAt.Equal(updated) { t.Fatalf("backfill bumped UpdatedAt: got %v want %v", meta.UpdatedAt, updated) @@ -142,10 +171,9 @@ func TestListSessionsTrustsRecordedEmptyWithoutDecoding(t *testing.T) { } } -// A genuinely-empty legacy session (no counts-aware meta) must be decoded once -// and then recorded as authoritative-empty, so later listings trust it instead -// of re-decoding the .jsonl on every refresh (the Turns == 0 overload bug). -func TestListSessionsRecordsEmptyLegacySessionOnce(t *testing.T) { +// A legacy artifact that might be empty is shown as unknown until the catalog +// repair worker validates it; listing cannot know without decoding. +func TestListSessionsShowsPotentiallyEmptyLegacySessionAsUnknown(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl") writeSessionFile(t, path, []provider.Message{ @@ -157,20 +185,16 @@ func TestListSessionsRecordsEmptyLegacySessionOnce(t *testing.T) { if err != nil { t.Fatalf("ListSessions: %v", err) } - if len(infos) != 0 { - t.Fatalf("empty session must not be listed; got %d", len(infos)) + if len(infos) != 1 || infos[0].CountsKnown { + t.Fatalf("legacy session should be visible as unknown; got %+v", infos) } - meta, ok, err := LoadBranchMeta(path) - if err != nil || !ok { - t.Fatalf("empty session should have been stamped: ok=%v err=%v", ok, err) - } - if meta.SchemaVersion != BranchMetaCountsVersion || meta.Turns != 0 { - t.Fatalf("empty session not recorded as authoritative-empty: version=%d turns=%d", meta.SchemaVersion, meta.Turns) + if _, ok, err := LoadBranchMeta(path); err != nil || ok { + t.Fatalf("listing unexpectedly created metadata: ok=%v err=%v", ok, err) } } -func TestListSessionsRepairsPreviouslyCachedZeroWhenReadable(t *testing.T) { +func TestListSessionsDefersPreviouslyCachedZeroRepair(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl") writeSessionFile(t, path, []provider.Message{ @@ -189,19 +213,19 @@ func TestListSessionsRepairsPreviouslyCachedZeroWhenReadable(t *testing.T) { if err != nil { t.Fatalf("ListSessions: %v", err) } - if len(infos) != 1 || infos[0].Turns != 1 || infos[0].Preview != "recovered question" { - t.Fatalf("recovered session was not restored to the listing: %+v", infos) + if len(infos) != 1 || infos[0].Turns != 0 || infos[0].CountsKnown || !strings.Contains(infos[0].Preview, "being indexed") { + t.Fatalf("legacy zero was not exposed as unknown: %+v", infos) } meta, ok, err := LoadBranchMeta(path) if err != nil || !ok { t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err) } - if meta.SchemaVersion != BranchMetaCountsVersion || meta.Turns != 1 || meta.Preview != "recovered question" { - t.Fatalf("recovered listing metadata was not repaired: %+v", meta) + if meta.SchemaVersion != branchMetaCountsInitialVersion || meta.Turns != 0 || meta.Preview != "" { + t.Fatalf("listing unexpectedly repaired metadata: %+v", meta) } } -func TestListSessionsValidatesOldRecordedEmptyOnce(t *testing.T) { +func TestListSessionsDefersOldRecordedEmptyValidation(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "20260101-000000-deepseek-chat.jsonl") writeSessionFile(t, path, []provider.Message{ @@ -220,15 +244,15 @@ func TestListSessionsValidatesOldRecordedEmptyOnce(t *testing.T) { if err != nil { t.Fatalf("ListSessions (migration): %v", err) } - if len(infos) != 0 { - t.Fatalf("validated empty session must stay hidden; got %+v", infos) + if len(infos) != 1 || infos[0].CountsKnown { + t.Fatalf("old zero should remain visible as unknown; got %+v", infos) } meta, ok, err := LoadBranchMeta(path) if err != nil || !ok { t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err) } - if meta.SchemaVersion != BranchMetaCountsVersion || meta.Turns != 0 { - t.Fatalf("empty session was not stamped as validated: %+v", meta) + if meta.SchemaVersion != branchMetaCountsInitialVersion || meta.Turns != 0 { + t.Fatalf("listing changed old zero metadata: %+v", meta) } // Current-version zero counts are authoritative. Making the artifact invalid @@ -240,8 +264,8 @@ func TestListSessionsValidatesOldRecordedEmptyOnce(t *testing.T) { if err != nil { t.Fatalf("ListSessions (steady state): %v", err) } - if len(infos) != 0 { - t.Fatalf("validated empty session was unexpectedly re-probed: %+v", infos) + if len(infos) != 1 || infos[0].CountsKnown { + t.Fatalf("unknown session should stay visible without re-probe: %+v", infos) } } @@ -276,7 +300,7 @@ func TestListSessionsKeepsUnreadableNonEmptySessionsVisible(t *testing.T) { if len(infos) != 1 { t.Fatalf("unreadable non-empty session must remain visible; got %d entries", len(infos)) } - if infos[0].Turns != 0 || !strings.Contains(infos[0].Preview, "may be corrupted") { + if infos[0].Turns != 0 || infos[0].CountsKnown || !strings.Contains(infos[0].Preview, "being indexed") { t.Fatalf("unexpected corrupt-session listing: %+v", infos[0]) } diff --git a/internal/agent/save.go b/internal/agent/save.go index 443f4a93e2..a88f7fb07c 100644 --- a/internal/agent/save.go +++ b/internal/agent/save.go @@ -368,9 +368,11 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error { } } s.markPersisted(path, digest, version, revision, rewriteVersion) + persistSessionListingProjection(path, msgs) return nil } s.markPersisted(path, digest, version, decision.revision, rewriteVersion) + persistSessionListingProjection(path, msgs) return nil } if decision.appendOnly && probe.native && mode != sessionSaveRewriteCompact { @@ -421,6 +423,7 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error { } } s.markPersisted(path, digest, version, revision, rewriteVersion) + persistSessionListingProjection(path, msgs) return nil } baseRevision = decision.revision @@ -481,9 +484,25 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error { slog.Warn("session: keeping save after display index write failure", "path", path, "err", err) } s.markPersisted(path, digest, version, revision, rewriteVersion) + persistSessionListingProjection(path, msgs) return nil } +func persistSessionListingProjection(path string, msgs []provider.Message) { + preview, turns := SessionPreviewFromMessages(msgs) + if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error { + meta.Preview = preview + meta.Turns = turns + meta.SchemaVersion = BranchMetaCountsVersion + return nil + }); err != nil { + // JSONL/event log already committed. Listing metadata is a repairable + // projection and must never turn a successful transcript save into an + // application error. + slog.Warn("session: listing metadata update deferred", "path", path, "err", err) + } +} + func writeSessionMessages(path string, msgs []provider.Message) error { // Write to a sibling tmp file then rename, so a crash mid-write can't // leave a partial JSONL that won't reload. The fsync guards the anchor @@ -1592,6 +1611,7 @@ type SessionInfo struct { ModTime time.Time // compatibility alias for LastActivityAt Preview string Turns int + CountsKnown bool Scope string WorkspaceRoot string TopicID string @@ -2212,8 +2232,9 @@ func ListSessionOrder(dir string) ([]SessionOrderInfo, error) { // ListSessions returns every non-empty *.jsonl session under dir, // most-recently-active first, each with a preview line so the picker can show -// something the user recognises. A missing directory is not an error — it just -// means there's nothing to resume yet. +// something the user recognises. It never decodes a transcript: legacy counts +// remain explicitly unknown until the session catalog's single repair worker +// validates them. A missing directory is not an error. func ListSessions(dir string) ([]SessionInfo, error) { ordered, err := ListSessionOrder(dir) if err != nil { @@ -2222,32 +2243,22 @@ func ListSessions(dir string) ([]SessionInfo, error) { var out []SessionInfo for _, session := range ordered { preview, turns := session.Preview, session.Turns - var previewErr error if sessionListingCountsNeedRefresh(session.SchemaVersion, turns) { - // The sidecar's counts weren't recorded from content (a legacy session - // from before they were persisted), or an old zero may have swallowed a - // decode error. Decode once, then backfill + stamp the sidecar so every - // later listing is O(1) and a recovered session becomes visible again. - preview, turns, previewErr = previewSessionWithError(session.Path) - if previewErr == nil { - // Compare-and-apply under the metadata lock. A turn-end save may have - // advanced the transcript and its counts while this listing decoded; - // never overwrite that newer state with a stale backfill. - preview, turns, _ = updateSessionListingCountsIfCurrent(session, preview, turns) - } - } - if turns == 0 { - hasData := sessionArtifactsHaveContent(session.Path) - if previewErr != nil && hasData { - preview = "Session may be corrupted — " + filepath.Base(session.Path) - out = append(out, sessionInfoFromOrder(session, preview, 0)) + if !sessionArtifactsHaveContent(session.Path) { continue } + if strings.TrimSpace(preview) == "" { + preview = "History is being indexed — " + filepath.Base(session.Path) + } + out = append(out, sessionInfoFromOrder(session, preview, turns, false)) + continue + } + if turns == 0 { // Never had user interaction — an empty conversation that should not // appear in the history panel or the resume picker. continue } - out = append(out, sessionInfoFromOrder(session, preview, turns)) + out = append(out, sessionInfoFromOrder(session, preview, turns, true)) } return out, nil } diff --git a/internal/agent/session_listing_helpers.go b/internal/agent/session_listing_helpers.go index d97d9ed0c8..a2669c9134 100644 --- a/internal/agent/session_listing_helpers.go +++ b/internal/agent/session_listing_helpers.go @@ -2,7 +2,7 @@ package agent import "os" -func sessionInfoFromOrder(session SessionOrderInfo, preview string, turns int) SessionInfo { +func sessionInfoFromOrder(session SessionOrderInfo, preview string, turns int, countsKnown bool) SessionInfo { return SessionInfo{ Path: session.Path, CreatedAt: session.CreatedAt, @@ -10,6 +10,7 @@ func sessionInfoFromOrder(session SessionOrderInfo, preview string, turns int) S ModTime: session.ModTime, Preview: preview, Turns: turns, + CountsKnown: countsKnown, Scope: session.Scope, WorkspaceRoot: session.WorkspaceRoot, TopicID: session.TopicID, diff --git a/internal/boot/extension_sidecar_test.go b/internal/boot/extension_sidecar_test.go index 73d9309caf..ecf7d1732a 100644 --- a/internal/boot/extension_sidecar_test.go +++ b/internal/boot/extension_sidecar_test.go @@ -325,6 +325,38 @@ func bootWithFakePlugin(t *testing.T, name string, runtime map[string]any) *Buil return res } +func TestBootIsolatesIncompatibleExternalPlugin(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + writeRuntimeFixture(t, dir) + + root := robustTempDir(t) + if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(`{"name":"irmia-devkit","version":"1.0.0"}`), 0o644); err != nil { + t.Fatal(err) + } + home := config.ReasonixHomeDir() + if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{Name: "irmia-devkit", Root: root, Enabled: true}); err != nil { + t.Fatal(err) + } + + res, err := BuildRuntime(context.Background(), Options{}) + if err != nil { + t.Fatalf("incompatible plugin blocked core controller: %v", err) + } + t.Cleanup(res.Controller.Close) + if res.Controller == nil || res.Extensions != nil { + t.Fatalf("build result = %#v, want core controller without extensions", res) + } + state, err := pluginpkg.LoadState(home) + if err != nil { + t.Fatal(err) + } + if len(state.Plugins) != 1 || state.Plugins[0].Status != pluginpkg.PluginStatusDisabledIncompatible { + t.Fatalf("plugin state = %#v", state.Plugins) + } +} + func TestBootStartsExtensionSidecar(t *testing.T) { res := bootWithFakePlugin(t, "bootplugin", map[string]any{ "intercepts": []string{"input.receive"}, diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 75efc580f5..0d62a5940e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -167,6 +167,9 @@ func RunWithBuildInfo(args []string, info BuildInfo) int { case "session": configureCLIThemeFromConfig() return sessionCommand(rest) + case "sessions": + configureCLIThemeFromConfig() + return sessionsCommand(rest) case "hook", "hooks": configureCLIThemeFromConfig() return hookCommand(rest) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 46ac76a56b..62415a37f1 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "flag" "fmt" @@ -9,9 +10,13 @@ import ( "reasonix/internal/doctor" "reasonix/internal/repair" + "reasonix/internal/sessioncatalog" ) func doctorCommand(args []string, version string) int { + if len(args) > 0 && args[0] == "sessions" { + return doctorSessionsCommand(args[1:]) + } if len(args) > 0 && args[0] == "quality" { return doctorQualityCommand(args[1:], version) } @@ -50,6 +55,42 @@ func doctorCommand(args []string, version string) int { return 0 } +func doctorSessionsCommand(args []string) int { + fs := flag.NewFlagSet("doctor sessions", flag.ContinueOnError) + jsonOut := fs.Bool("json", false, "print session catalog diagnostics as JSON") + if code, ok := parseCommandFlags(fs, args); !ok { + return code + } + if fs.NArg() != 0 { + fmt.Fprintln(os.Stderr, "usage: reasonix doctor sessions [--json]") + return 2 + } + status, err := sessioncatalog.Inspect(context.Background(), sessioncatalog.DefaultPath()) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + 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 + } + fmt.Println("Reasonix session catalog") + fmt.Printf(" state: %s\n", status.State) + fmt.Printf(" mode: %s\n", status.Mode) + fmt.Printf(" revision: %d\n", status.Revision) + fmt.Printf(" indexed: %d\n", status.Indexed) + fmt.Printf(" repair pending: %d\n", status.RepairPending) + if status.LastError != "" { + fmt.Printf(" note: %s\n", status.LastError) + } + return 0 +} + func doctorRepairCommand(args []string) int { fs := flag.NewFlagSet("doctor repair", flag.ContinueOnError) root := fs.String("root", ".", "project root to inspect") diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 0b2affd32d..4fd814372a 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -5,8 +5,11 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "testing" + + "reasonix/internal/config" ) func TestDoctorCommandPrintsJSON(t *testing.T) { @@ -36,6 +39,78 @@ func TestRunDispatchesDoctor(t *testing.T) { } } +func TestDoctorSessionsIsReadOnly(t *testing.T) { + cache := t.TempDir() + t.Setenv("REASONIX_CACHE_HOME", cache) + out := captureStdout(t, func() { + if rc := doctorCommand([]string{"sessions", "--json"}, "test-version"); rc != 0 { + t.Fatalf("doctor sessions rc = %d, want 0", rc) + } + }) + var decoded map[string]any + if err := json.Unmarshal([]byte(out), &decoded); err != nil { + t.Fatalf("doctor sessions output: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(cache, "session-catalog", "v1.sqlite")); !os.IsNotExist(err) { + t.Fatalf("doctor sessions created or changed catalog: %v", err) + } +} + +func TestSessionsReindexRebuildsOnlyCatalog(t *testing.T) { + cache := t.TempDir() + home := t.TempDir() + sessions := filepath.Join(home, "sessions") + t.Setenv("REASONIX_CACHE_HOME", cache) + t.Setenv("REASONIX_HOME", home) + if err := os.MkdirAll(sessions, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(sessions, "keep.jsonl") + body := []byte(`{"role":"user","content":"keep me"}` + "\n") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + out := captureStdout(t, func() { + if rc := sessionsCommand([]string{"reindex", "--dir", sessions, "--json"}); rc != 0 { + t.Fatalf("sessions reindex rc = %d, want 0", rc) + } + }) + if !json.Valid([]byte(out)) { + t.Fatalf("sessions reindex output is not JSON: %s", out) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != string(body) { + t.Fatalf("authoritative transcript changed: err=%v got=%q", err, got) + } +} + +func TestDefaultSessionCatalogTargetsIncludeDesktopProjects(t *testing.T) { + home := t.TempDir() + t.Setenv("REASONIX_HOME", home) + projectRoot := filepath.Join(t.TempDir(), "project") + projects := `{"projects":[{"root":` + strconv.Quote(projectRoot) + `}]}` + if err := os.WriteFile(filepath.Join(home, "desktop-projects.json"), []byte(projects), 0o600); err != nil { + t.Fatal(err) + } + targets := defaultSessionCatalogTargets() + want := map[string]string{ + filepath.Clean(filepath.Join(home, "sessions")): "global", + filepath.Clean(config.ProjectSessionDir(filepath.Join(home, "global-workspace"))): "global", + filepath.Clean(config.ProjectSessionDir(projectRoot)): "project", + } + if len(targets) != len(want) { + t.Fatalf("targets = %#v, want %d entries", targets, len(want)) + } + for _, target := range targets { + if scope, ok := want[target.Path]; !ok || target.Scope != scope { + t.Fatalf("unexpected target %#v (want %#v)", target, want) + } + if target.Scope == "project" && target.WorkspaceRoot != projectRoot { + t.Fatalf("project target lost workspace root: %#v", target) + } + } +} + func TestDoctorSessionCommandWritesBundle(t *testing.T) { home := t.TempDir() t.Setenv("REASONIX_HOME", home) diff --git a/internal/cli/plugin.go b/internal/cli/plugin.go index 86fcaa9f4a..c47893ae87 100644 --- a/internal/cli/plugin.go +++ b/internal/cli/plugin.go @@ -204,6 +204,9 @@ func pluginListCommand() int { if p.Enabled { state = "enabled" } + if strings.TrimSpace(p.Status) != "" { + state = p.Status + } version := p.Version if version == "" { version = "-" diff --git a/internal/cli/sessions_catalog.go b/internal/cli/sessions_catalog.go new file mode 100644 index 0000000000..8ccfef253c --- /dev/null +++ b/internal/cli/sessions_catalog.go @@ -0,0 +1,109 @@ +package cli + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "reasonix/internal/config" + "reasonix/internal/sessioncatalog" +) + +func sessionsCommand(args []string) int { + if len(args) == 0 || args[0] != "reindex" { + fmt.Fprintln(os.Stderr, "usage: reasonix sessions reindex [--dir PATH] [--json]") + return 2 + } + fs := flag.NewFlagSet("sessions reindex", flag.ContinueOnError) + var dirs stringListFlag + jsonOut := fs.Bool("json", false, "print the rebuilt catalog status as JSON") + fs.Var(&dirs, "dir", "session directory to index; repeat for multiple directories") + if code, ok := parseCommandFlags(fs, args[1:]); !ok { + return code + } + if fs.NArg() != 0 { + fmt.Fprintln(os.Stderr, "usage: reasonix sessions reindex [--dir PATH] [--json]") + return 2 + } + if len(dirs) == 0 { + status, err := sessioncatalog.Rebuild(context.Background(), sessioncatalog.DefaultPath(), defaultSessionCatalogTargets()) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + return printSessionCatalogRebuild(status, *jsonOut) + } + targets := make([]sessioncatalog.DirectoryTarget, 0, len(dirs)) + seen := map[string]bool{} + for _, dir := range dirs { + dir = filepath.Clean(strings.TrimSpace(dir)) + if dir == "." || dir == "" || seen[dir] { + continue + } + seen[dir] = true + targets = append(targets, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}) + } + status, err := sessioncatalog.Rebuild(context.Background(), sessioncatalog.DefaultPath(), targets) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + return printSessionCatalogRebuild(status, *jsonOut) +} + +func printSessionCatalogRebuild(status sessioncatalog.Status, 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 + } + fmt.Printf("rebuilt session catalog: %d sessions, revision %d\n", status.Indexed, status.Revision) + return 0 +} + +func defaultSessionCatalogTargets() []sessioncatalog.DirectoryTarget { + type project struct { + Root string `json:"root"` + } + type projectFile struct { + Projects []project `json:"projects"` + } + home := config.ReasonixHomeDir() + var saved projectFile + if data, err := os.ReadFile(filepath.Join(home, "desktop-projects.json")); err == nil { + _ = json.Unmarshal(data, &saved) + } + seen := map[string]bool{} + targets := make([]sessioncatalog.DirectoryTarget, 0, len(saved.Projects)+2) + add := func(target sessioncatalog.DirectoryTarget) { + target.Path = filepath.Clean(strings.TrimSpace(target.Path)) + if target.Path == "." || target.Path == "" || seen[target.Path] { + return + } + seen[target.Path] = true + targets = append(targets, target) + } + add(sessioncatalog.DirectoryTarget{Path: config.SessionDir(), Scope: "global"}) + add(sessioncatalog.DirectoryTarget{ + Path: config.ProjectSessionDir(filepath.Join(home, "global-workspace")), + Scope: "global", + }) + for _, savedProject := range saved.Projects { + root := strings.TrimSpace(savedProject.Root) + if root == "" { + continue + } + add(sessioncatalog.DirectoryTarget{ + Path: config.ProjectSessionDir(root), Scope: "project", WorkspaceRoot: root, + }) + } + return targets +} diff --git a/internal/cli/shell_completion.go b/internal/cli/shell_completion.go index 8adc421697..8f15100487 100644 --- a/internal/cli/shell_completion.go +++ b/internal/cli/shell_completion.go @@ -202,6 +202,7 @@ func cliCompletionRootSpec() cliCompletionSpec { completionSpec("run", []cliCompletionFlag{model, completionFlag("--max-steps --dir", cliCompletionStaticValue), help}), ), completionSpec("doctor", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}, + completionSpec("sessions", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}), completionSpec("repair", []cliCompletionFlag{ completionFlag("--root", cliCompletionStaticValue), completionFlag("--apply --project --json", cliCompletionNoValue), help, }), @@ -212,6 +213,11 @@ func cliCompletionRootSpec() cliCompletionSpec { completionFlag("--root --timeout", cliCompletionStaticValue), completionFlag("--json --live", cliCompletionNoValue), help, }), ), + completionSpec("sessions", []cliCompletionFlag{help}, + completionSpec("reindex", []cliCompletionFlag{ + completionFlag("--json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help, + }), + ), completionSpec("report", []cliCompletionFlag{help}, completionSpec("list", []cliCompletionFlag{help}), completionSpec("show", []cliCompletionFlag{help}), diff --git a/internal/doctor/session_redact.go b/internal/doctor/session_redact.go index 55836b9a05..fcbff80174 100644 --- a/internal/doctor/session_redact.go +++ b/internal/doctor/session_redact.go @@ -49,6 +49,7 @@ func RedactSessions(opts RedactSessionsOptions) RedactSessionsResult { dirs := redactSessionDirs(opts.Dirs) res := RedactSessionsResult{Dirs: dirs, DryRun: opts.DryRun} for _, dir := range dirs { + var candidates []string if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", path, err)) @@ -60,26 +61,49 @@ func RedactSessions(opts RedactSessionsOptions) RedactSessionsResult { if d.IsDir() || !redactSessionCandidate(path) { return nil } - res.FilesScanned++ - if sessionPath := redactionSessionPath(path); sessionPath != "" && sessionRedactionLeaseHeld(sessionPath) { - res.FilesSkipped++ - return nil - } - changed, rewritten, err := redactSessionArtifact(path, opts.DryRun) - if err != nil { - res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", path, err)) - return nil - } - res.FilesChanged += changed - res.BytesRewritten += rewritten + candidates = append(candidates, path) return nil }); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", dir, err)) } + // A transcript rewrite now refreshes its listing projection. Process an + // existing branch-meta sidecar first so a secret-bearing sidecar is + // counted and redacted before the transcript replaces its preview. + sort.SliceStable(candidates, func(i, j int) bool { + left, right := redactionCandidatePriority(candidates[i]), redactionCandidatePriority(candidates[j]) + if left != right { + return left < right + } + return candidates[i] < candidates[j] + }) + for _, path := range candidates { + res.FilesScanned++ + if sessionPath := redactionSessionPath(path); sessionPath != "" && sessionRedactionLeaseHeld(sessionPath) { + res.FilesSkipped++ + continue + } + changed, rewritten, err := redactSessionArtifact(path, opts.DryRun) + if err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", path, err)) + continue + } + res.FilesChanged += changed + res.BytesRewritten += rewritten + } } return res } +func redactionCandidatePriority(path string) int { + if strings.HasSuffix(filepath.Base(path), ".jsonl.meta") { + return 0 + } + if store.IsSessionTranscriptName(filepath.Base(path)) { + return 1 + } + return 2 +} + func redactSessionDirs(in []string) []string { var candidates []string if len(in) > 0 { diff --git a/internal/pluginpkg/compat_legacy_test.go b/internal/pluginpkg/compat_legacy_test.go index 9104730cd2..c8e659ac57 100644 --- a/internal/pluginpkg/compat_legacy_test.go +++ b/internal/pluginpkg/compat_legacy_test.go @@ -53,3 +53,62 @@ func TestV1NativeManifestRejected(t *testing.T) { t.Fatalf("ParseNativeForMigrate v1 = %v, want unsupported apiVersion", err) } } + +func TestLoadInstalledAutoMigratesManagedLegacyManifest(t *testing.T) { + home := t.TempDir() + root := InstallRoot(home, "legacy-demo") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + legacy := `{"name":"legacy-demo","version":"1.0.0"}` + if err := os.WriteFile(filepath.Join(root, NativeManifest), []byte(legacy), 0o644); err != nil { + t.Fatal(err) + } + if err := Upsert(home, InstalledPlugin{Name: "legacy-demo", Root: RelativeRoot(home, root), Enabled: true}); err != nil { + t.Fatal(err) + } + + installed, warnings := LoadInstalled(home) + if len(warnings) != 1 || !strings.Contains(warnings[0], "automatically migrated") { + t.Fatalf("warnings = %v", warnings) + } + if len(installed) != 1 || installed[0].Package.Manifest.APIVersion != ManifestAPIVersionV2 { + t.Fatalf("installed = %#v", installed) + } + backup, err := os.ReadFile(filepath.Join(root, NativeManifest+".bak")) + if err != nil || string(backup) != legacy { + t.Fatalf("backup = %q, err=%v", backup, err) + } +} + +func TestLoadInstalledQuarantinesExternalLegacyManifestWithoutModifyingIt(t *testing.T) { + home := t.TempDir() + root := t.TempDir() + legacy := `{"name":"dev-demo","version":"1.0.0"}` + manifestPath := filepath.Join(root, NativeManifest) + if err := os.WriteFile(manifestPath, []byte(legacy), 0o644); err != nil { + t.Fatal(err) + } + if err := Upsert(home, InstalledPlugin{Name: "dev-demo", Root: root, Enabled: true}); err != nil { + t.Fatal(err) + } + + installed, warnings := LoadInstalled(home) + if len(installed) != 0 { + t.Fatalf("external incompatible plugin loaded: %#v", installed) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], PluginStatusDisabledIncompatible) || !strings.Contains(warnings[0], "plugin migrate dev-demo --to-v2") { + t.Fatalf("warnings = %v", warnings) + } + after, err := os.ReadFile(manifestPath) + if err != nil || string(after) != legacy { + t.Fatalf("external manifest changed: %q, err=%v", after, err) + } + state, err := LoadState(home) + if err != nil { + t.Fatal(err) + } + if len(state.Plugins) != 1 || state.Plugins[0].Status != PluginStatusDisabledIncompatible { + t.Fatalf("plugin state = %#v", state.Plugins) + } +} diff --git a/internal/pluginpkg/manifest_v2.go b/internal/pluginpkg/manifest_v2.go index 682a9cb35e..9ea9e3e79d 100644 --- a/internal/pluginpkg/manifest_v2.go +++ b/internal/pluginpkg/manifest_v2.go @@ -9,13 +9,15 @@ import ( "strings" "reasonix/internal/extensioncontract" + "reasonix/internal/fileutil" fileencoding "reasonix/internal/fileutil/encoding" ) // ManifestAPIVersionV2 is the only native plugin manifest apiVersion Reasonix // accepts for install, doctor, and boot. v1 and legacy (no apiVersion) native -// manifests are rejected. The explicit migrate command is only for legacy -// pre-extension manifests that omit apiVersion. +// manifests are rejected by the parser. Legacy pre-extension manifests can be +// upgraded explicitly, or automatically when they live in Reasonix's managed +// plugin directory where an atomic backup is safe. const ManifestAPIVersionV2 = "reasonix.io/plugin/v2" // CapabilityRef is the wire form of a capability in a v2 manifest. @@ -370,14 +372,14 @@ func WriteMigratedManifestV2(root string, data []byte) error { path := filepath.Join(root, NativeManifest) if b, err := os.ReadFile(path); err == nil { bak := path + ".bak" - if err := os.WriteFile(bak, b, 0o644); err != nil { + if err := fileutil.AtomicWriteFile(bak, b, 0o644); err != nil { return fmt.Errorf("migrate: backup: %w", err) } } if !strings.HasSuffix(string(data), "\n") { data = append(data, '\n') } - return os.WriteFile(path, data, 0o644) + return fileutil.AtomicWriteFile(path, data, 0o644) } // ParseNativeForMigrate accepts legacy native manifests without apiVersion for diff --git a/internal/pluginpkg/pluginpkg.go b/internal/pluginpkg/pluginpkg.go index f4bed0a103..d68f3fa720 100644 --- a/internal/pluginpkg/pluginpkg.go +++ b/internal/pluginpkg/pluginpkg.go @@ -29,6 +29,8 @@ const ( ClaudeManifest = ".claude-plugin/plugin.json" StateFilename = "plugin-packages.json" + PluginStatusDisabledIncompatible = "disabled_incompatible" + claudeSettingsPath = ".claude/settings.json" claudeInstructions = "CLAUDE.md" ) @@ -255,6 +257,8 @@ type InstalledPlugin struct { ManifestKind string `json:"manifestKind,omitempty"` Enabled bool `json:"enabled"` Commit string `json:"commit,omitempty"` + Status string `json:"status,omitempty"` + StatusReason string `json:"statusReason,omitempty"` } type InstalledPackage struct { @@ -370,28 +374,91 @@ func SetEnabled(reasonixHome, name string, enabled bool) error { } func LoadInstalled(reasonixHome string) ([]InstalledPackage, []string) { + stateMu.Lock() + defer stateMu.Unlock() st, err := LoadState(reasonixHome) if err != nil { return nil, []string{err.Error()} } var out []InstalledPackage var warnings []string - for _, installed := range st.Plugins { + stateChanged := false + for i := range st.Plugins { + installed := st.Plugins[i] if !installed.Enabled { continue } root := ResolveRoot(reasonixHome, installed.Root) pkg, pkgWarnings, err := ParseDir(root) + if err != nil && strings.Contains(err.Error(), "missing apiVersion") && managedPluginRoot(reasonixHome, installed, root) { + legacy, _, migrateErr := ParseNativeForMigrate(root) + if migrateErr == nil { + var data []byte + data, migrateErr = MigrateManifestToV2(legacy) + if migrateErr == nil { + migrateErr = WriteMigratedManifestV2(root, data) + } + } + if migrateErr == nil { + pkg, pkgWarnings, err = ParseDir(root) + if err == nil { + warnings = append(warnings, fmt.Sprintf("%s: automatically migrated managed manifest to %s (backup: %s.bak)", installed.Name, ManifestAPIVersionV2, NativeManifest)) + } + } else { + err = fmt.Errorf("automatic managed-plugin migration failed: %w", migrateErr) + } + } if err != nil { - warnings = append(warnings, fmt.Sprintf("%s: %v", installed.Name, err)) + reason := err.Error() + if st.Plugins[i].Status != PluginStatusDisabledIncompatible || st.Plugins[i].StatusReason != reason { + st.Plugins[i].Status = PluginStatusDisabledIncompatible + st.Plugins[i].StatusReason = reason + stateChanged = true + } + remediation := "repair or reinstall the plugin" + if strings.Contains(reason, "missing apiVersion") { + remediation = fmt.Sprintf("reasonix plugin migrate %s --to-v2", installed.Name) + } + warnings = append(warnings, fmt.Sprintf("%s: %s: %v; remediation: %s", installed.Name, PluginStatusDisabledIncompatible, err, remediation)) continue } + if st.Plugins[i].Status != "" || st.Plugins[i].StatusReason != "" { + st.Plugins[i].Status = "" + st.Plugins[i].StatusReason = "" + stateChanged = true + } out = append(out, InstalledPackage{Installed: installed, Package: pkg, Warnings: pkgWarnings}) } + if stateChanged { + if err := SaveState(reasonixHome, st); err != nil { + warnings = append(warnings, "persist plugin compatibility status: "+err.Error()) + } + } sort.SliceStable(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name }) return out, warnings } +func managedPluginRoot(reasonixHome string, installed InstalledPlugin, root string) bool { + if filepath.IsAbs(strings.TrimSpace(installed.Root)) { + return false + } + managed := filepath.Clean(PluginsDir(reasonixHome)) + rel, err := filepath.Rel(managed, filepath.Clean(root)) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + current := managed + for _, part := range strings.Split(rel, string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return false + } + } + info, err := os.Lstat(filepath.Join(root, NativeManifest)) + return err == nil && info.Mode()&os.ModeSymlink == 0 +} + func ResolveRoot(reasonixHome, root string) string { if filepath.IsAbs(root) { return filepath.Clean(root) diff --git a/internal/sessioncatalog/catalog.go b/internal/sessioncatalog/catalog.go new file mode 100644 index 0000000000..bd79714328 --- /dev/null +++ b/internal/sessioncatalog/catalog.go @@ -0,0 +1,763 @@ +package sessioncatalog + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "time" + + _ "modernc.org/sqlite" +) + +const defaultMissingGrace = 30 * time.Second + +type Catalog struct { + db *sql.DB + opts Options + revision atomic.Uint64 + statusMu sync.RWMutex + status Status + writeCh chan string + writeMu sync.Mutex + writeQueued map[string]SessionRecord + mutationMu sync.RWMutex + removedPaths sync.Map + repairCh chan string + repairQueued sync.Map + reconcileCh chan DirectoryTarget + reconcileQueued sync.Map + pathCh chan sessionPathRequest + pathQueued sync.Map + directoryLocksMu sync.Mutex + directoryLocks map[string]*sync.Mutex + workerCtx context.Context + workerCancel context.CancelFunc + stop chan struct{} + stopOnce sync.Once + workers sync.WaitGroup + closeDBOnce sync.Once +} + +type sessionPathRequest struct { + target DirectoryTarget + path string +} + +type pageCursor struct { + Pinned int `json:"p"` + Activity int64 `json:"a"` + TopicID string `json:"t"` +} + +func Open(ctx context.Context, opts Options) (*Catalog, error) { + if opts.Path == "" { + opts.Path = DefaultPath() + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.MissingGrace <= 0 { + opts.MissingGrace = defaultMissingGrace + } + if opts.QueueCapacity <= 0 { + opts.QueueCapacity = 1024 + } + + c := &Catalog{ + opts: opts, + writeCh: make(chan string, opts.QueueCapacity), + writeQueued: map[string]SessionRecord{}, + repairCh: make(chan string, opts.QueueCapacity), + reconcileCh: make(chan DirectoryTarget, 64), + pathCh: make(chan sessionPathRequest, opts.QueueCapacity), + directoryLocks: map[string]*sync.Mutex{}, + 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) + } + if err != nil { + return nil, err + } + c.db = db + c.status.Mode = mode + c.status.State = StateReady + if mode == ModeMemory { + c.status.Path = "" + } + if err := c.loadStatus(ctx); err != nil { + _ = db.Close() + return nil, err + } + c.workerCtx, c.workerCancel = context.WithCancel(context.Background()) + c.workers.Add(1) + go c.writerLoop() + c.workers.Add(1) + go c.reconcileLoop() + c.workers.Add(1) + go c.sessionPathLoop() + if !opts.DisableRepair { + c.workers.Add(1) + go c.repairLoop() + c.enqueuePersistedRepairs(ctx) + } + 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 { + return err + } + c.revision.Store(revision) + c.statusMu.Lock() + c.status.Revision = revision + c.statusMu.Unlock() + c.refreshCounts(ctx) + return nil +} + +func (c *Catalog) Status() Status { + if c == nil { + return Status{State: StateDegraded, Mode: ModeMemory, LastError: "session catalog unavailable"} + } + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return c.status +} + +func (c *Catalog) refreshCounts(ctx context.Context) { + if c == nil || c.db == nil { + return + } + var indexed, pending, total int64 + _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions`).Scan(&indexed) + _ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown'`).Scan(&pending) + _ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(total), 0) FROM catalog_directories`).Scan(&total) + c.statusMu.Lock() + c.status.Indexed = indexed + c.status.Total = total + c.status.RepairPending = pending + c.status.Revision = c.revision.Load() + c.statusMu.Unlock() +} + +func normalizeScope(scope, root string) (string, string) { + if strings.TrimSpace(scope) != "project" { + return "global", "" + } + return "project", strings.TrimSpace(root) +} + +func normalizeSessionRecord(record SessionRecord) SessionRecord { + record.Path = filepath.Clean(record.Path) + if record.Directory == "" { + record.Directory = filepath.Dir(record.Path) + } + record.Directory = filepath.Clean(record.Directory) + record.Scope, record.WorkspaceRoot = normalizeScope(record.Scope, record.WorkspaceRoot) + if record.TurnsState == "" { + record.TurnsState = TurnsUnknown + } + if record.Health == "" { + record.Health = HealthOK + } + return record +} + +func (c *Catalog) EnqueueSession(record SessionRecord) bool { + if c == nil { + return false + } + record = normalizeSessionRecord(record) + c.removedPaths.Delete(record.Path) + c.writeMu.Lock() + if _, loaded := c.writeQueued[record.Path]; loaded { + c.writeQueued[record.Path] = record + c.writeMu.Unlock() + return true + } + c.writeQueued[record.Path] = record + select { + case <-c.stop: + delete(c.writeQueued, record.Path) + c.writeMu.Unlock() + return false + case c.writeCh <- record.Path: + c.writeMu.Unlock() + return true + default: + delete(c.writeQueued, record.Path) + c.writeMu.Unlock() + return false + } +} + +func (c *Catalog) takeQueuedWrite(path string) (SessionRecord, bool) { + c.writeMu.Lock() + defer c.writeMu.Unlock() + record, ok := c.writeQueued[path] + if ok { + delete(c.writeQueued, path) + } + return record, ok +} + +func (c *Catalog) writerLoop() { + defer c.workers.Done() + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + pending := map[string]SessionRecord{} + flush := func() { + if len(pending) == 0 { + return + } + records := make([]SessionRecord, 0, len(pending)) + for _, record := range pending { + records = append(records, record) + } + pending = map[string]SessionRecord{} + ctx, cancel := context.WithTimeout(c.workerCtx, time.Second) + _ = c.upsertSessions(ctx, records, nil, "write") + cancel() + } + for { + select { + case path := <-c.writeCh: + if record, ok := c.takeQueuedWrite(path); ok { + pending[path] = record + } + if len(pending) >= 64 { + flush() + } + case <-ticker.C: + flush() + case <-c.stop: + for { + select { + case path := <-c.writeCh: + if record, ok := c.takeQueuedWrite(path); ok { + pending[path] = record + } + default: + flush() + return + } + } + } + } +} + +func (c *Catalog) UpsertSession(ctx context.Context, record SessionRecord) error { + return c.upsertSessions(ctx, []SessionRecord{normalizeSessionRecord(record)}, nil, "write") +} + +func (c *Catalog) upsertSessions(ctx context.Context, records []SessionRecord, generations map[string]int64, reason string) error { + if len(records) == 0 { + return nil + } + c.mutationMu.RLock() + defer c.mutationMu.RUnlock() + filtered := records[:0] + for _, record := range records { + if _, removed := c.removedPaths.Load(filepath.Clean(record.Path)); !removed { + filtered = append(filtered, record) + } + } + records = filtered + if len(records) == 0 { + return nil + } + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + affected := map[TopicKey]struct{}{} + roots := map[string]struct{}{} + directoryGenerations := map[string]int64{} + for _, raw := range records { + record := normalizeSessionRecord(raw) + var previous TopicKey + if err := tx.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id FROM catalog_sessions WHERE path=?`, record.Path). + Scan(&previous.Scope, &previous.WorkspaceRoot, &previous.TopicID); err == nil && previous.TopicID != "" { + affected[previous] = struct{}{} + } else if err != nil && !errors.Is(err, sql.ErrNoRows) { + _ = tx.Rollback() + return err + } + generation := int64(0) + if generations != nil { + generation = generations[record.Path] + } else if cached, ok := directoryGenerations[record.Directory]; ok { + generation = cached + } else { + _ = tx.QueryRowContext(ctx, `SELECT scan_generation FROM catalog_directories WHERE path=?`, record.Directory).Scan(&generation) + directoryGenerations[record.Directory] = generation + } + if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_sessions( + 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,seen_generation + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(path) DO UPDATE SET + directory=excluded.directory, scope=excluded.scope, + workspace_root=excluded.workspace_root, topic_id=excluded.topic_id, + topic_title=excluded.topic_title, custom_title=excluded.custom_title, + created_at=excluded.created_at, last_activity_at=excluded.last_activity_at, + preview=excluded.preview, turns=excluded.turns, + turns_state=excluded.turns_state, recovered=excluded.recovered, + recovery_reason=excluded.recovery_reason, + recovery_digest=excluded.recovery_digest, parent_id=excluded.parent_id, + content_fingerprint=excluded.content_fingerprint, + meta_fingerprint=excluded.meta_fingerprint, health=excluded.health, + missing_since=0, seen_generation=MAX(catalog_sessions.seen_generation, excluded.seen_generation)`, + 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, 0, generation); err != nil { + _ = tx.Rollback() + return err + } + if record.TopicID != "" { + affected[TopicKey{Scope: record.Scope, WorkspaceRoot: record.WorkspaceRoot, TopicID: record.TopicID}] = struct{}{} + } + roots[record.WorkspaceRoot] = struct{}{} + } + for key := range affected { + if err := recomputeTopic(ctx, tx, key); err != nil { + _ = tx.Rollback() + return err + } + } + revision, err := bumpRevision(ctx, tx) + if err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + c.publishRevision(revision, mapKeys(roots), reason) + c.refreshCounts(ctx) + return nil +} + +// RemoveSession immediately removes one projection row after an authoritative +// archive/delete. It never touches the transcript or sidecars. A tombstone +// suppresses writer work captured before the removal; a later exact index, +// save, or fresh directory scan clears it when the path is recreated. +func (c *Catalog) RemoveSession(ctx context.Context, path, reason string) error { + if c == nil || c.db == nil { + return nil + } + path = filepath.Clean(strings.TrimSpace(path)) + if path == "." || path == "" { + return nil + } + // Serialize an authoritative removal with directory reconciliation. Without + // this boundary a scan that captured the old path just before an archive + // could clear the tombstone and reinsert the stale projection afterwards. + directoryLock := c.directoryLock(filepath.Dir(path)) + directoryLock.Lock() + defer directoryLock.Unlock() + c.mutationMu.Lock() + defer c.mutationMu.Unlock() + c.removedPaths.Store(path, struct{}{}) + c.writeMu.Lock() + delete(c.writeQueued, path) + c.writeMu.Unlock() + c.pathQueued.Delete(path) + c.repairQueued.Delete(path) + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + var key TopicKey + err = tx.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id FROM catalog_sessions WHERE path=?`, path). + Scan(&key.Scope, &key.WorkspaceRoot, &key.TopicID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + _ = tx.Rollback() + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM catalog_sessions WHERE path=?`, path); err != nil { + _ = tx.Rollback() + return err + } + if key.TopicID != "" { + if err := recomputeTopic(ctx, tx, key); err != nil { + _ = tx.Rollback() + return err + } + } + revision, err := bumpRevision(ctx, tx) + if err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + c.publishRevision(revision, []string{key.WorkspaceRoot}, reason) + c.refreshCounts(ctx) + return nil +} + +func recomputeTopic(ctx context.Context, tx *sql.Tx, key TopicKey) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE scope=? AND workspace_root=? AND topic_id=?`, key.Scope, key.WorkspaceRoot, key.TopicID).Scan(&count); err != nil { + return err + } + if count == 0 { + _, err := tx.ExecContext(ctx, `DELETE FROM catalog_topics WHERE scope=? AND workspace_root=? AND topic_id=?`, key.Scope, key.WorkspaceRoot, key.TopicID) + return err + } + _, err := tx.ExecContext(ctx, `INSERT INTO catalog_topics( + scope,workspace_root,topic_id,title,turns,turns_state,created_at, + last_activity_at,recovery_state,health + ) SELECT ?,?,?, + COALESCE(NULLIF((SELECT COALESCE(NULLIF(custom_title,''), NULLIF(topic_title,''), preview, '') + FROM catalog_sessions WHERE scope=? AND workspace_root=? AND topic_id=? + ORDER BY last_activity_at DESC, path ASC LIMIT 1),''), ?), + COALESCE(SUM(CASE WHEN turns_state='valid' THEN turns ELSE 0 END),0), + CASE WHEN SUM(CASE WHEN turns_state='corrupt' THEN 1 ELSE 0 END)>0 THEN 'corrupt' + WHEN SUM(CASE WHEN turns_state='unknown' THEN 1 ELSE 0 END)>0 THEN 'unknown' + ELSE 'valid' END, + COALESCE(MIN(NULLIF(created_at,0)),0), COALESCE(MAX(last_activity_at),0), + CASE WHEN SUM(CASE WHEN recovered=1 THEN 1 ELSE 0 END)=COUNT(*) THEN 'recovery_only' ELSE '' END, + CASE WHEN SUM(CASE WHEN health='corrupt' THEN 1 ELSE 0 END)>0 THEN 'corrupt' + WHEN SUM(CASE WHEN health='missing' THEN 1 ELSE 0 END)>0 THEN 'missing' + ELSE 'ok' END + FROM catalog_sessions WHERE scope=? AND workspace_root=? AND topic_id=? + ON CONFLICT(scope,workspace_root,topic_id) DO UPDATE SET + title=excluded.title, turns=excluded.turns, turns_state=excluded.turns_state, + created_at=excluded.created_at, last_activity_at=excluded.last_activity_at, + recovery_state=excluded.recovery_state, health=excluded.health`, + key.Scope, key.WorkspaceRoot, key.TopicID, + key.Scope, key.WorkspaceRoot, key.TopicID, key.TopicID, + key.Scope, key.WorkspaceRoot, key.TopicID) + return err +} + +func bumpRevision(ctx context.Context, tx *sql.Tx) (uint64, error) { + if _, err := tx.ExecContext(ctx, `UPDATE catalog_state SET revision=revision+1 WHERE id=1`); err != nil { + return 0, err + } + var revision uint64 + if err := tx.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&revision); err != nil { + return 0, err + } + return revision, nil +} + +func (c *Catalog) publishRevision(revision uint64, roots []string, reason string) { + c.revision.Store(revision) + c.statusMu.Lock() + c.status.Revision = revision + c.statusMu.Unlock() + if c.opts.OnRevision != nil { + c.opts.OnRevision(revision, roots, reason) + } +} + +func mapKeys(values map[string]struct{}) []string { + out := make([]string, 0, len(values)) + for value := range values { + out = append(out, value) + } + return out +} + +func (c *Catalog) ListTopics(ctx context.Context, req TopicPageRequest) (TopicPage, error) { + out := TopicPage{Items: []TopicRecord{}, Revision: c.revision.Load()} + req.Scope, req.WorkspaceRoot = normalizeScope(req.Scope, req.WorkspaceRoot) + if req.Limit <= 0 { + req.Limit = DefaultLimit + } + if req.Limit > MaxLimit { + req.Limit = MaxLimit + } + cursor, err := decodeCursor(req.Cursor) + if err != nil { + return out, err + } + args := []any{req.Scope, req.WorkspaceRoot} + where := `scope=? AND workspace_root=?` + if query := strings.TrimSpace(req.Query); query != "" { + where += ` AND lower(title) LIKE ?` + args = append(args, "%"+strings.ToLower(query)+"%") + } + if cutoff := timeFilterCutoff(req.TimeFilter, c.opts.Now()); cutoff > 0 { + where += ` AND last_activity_at>=?` + args = append(args, cutoff) + } + if cursor != nil { + where += ` AND (pinned?))` + args = append(args, cursor.Pinned, cursor.Pinned, cursor.Activity, cursor.Pinned, cursor.Activity, cursor.TopicID) + } + args = append(args, req.Limit+1) + rows, err := c.db.QueryContext(ctx, `SELECT scope,workspace_root,topic_id,title,pinned,sort_order, + turns,turns_state,created_at,last_activity_at,recovery_state,health + FROM catalog_topics WHERE `+where+` + ORDER BY pinned DESC,last_activity_at DESC,topic_id ASC LIMIT ?`, args...) + if err != nil { + return out, err + } + defer rows.Close() + for rows.Next() { + var item TopicRecord + if err := rows.Scan(&item.Scope, &item.WorkspaceRoot, &item.TopicID, &item.Title, + &item.Pinned, &item.SortOrder, &item.Turns, &item.TurnsState, + &item.CreatedAt, &item.LastActivityAt, &item.RecoveryState, &item.Health); err != nil { + return out, err + } + item.Sessions = []SessionRecord{} + out.Items = append(out.Items, item) + } + if err := rows.Err(); err != nil { + return out, err + } + more := len(out.Items) > req.Limit + if more { + out.Items = out.Items[:req.Limit] + } + for i := range out.Items { + sessions, err := c.listTopicSessions(ctx, TopicKey{Scope: out.Items[i].Scope, WorkspaceRoot: out.Items[i].WorkspaceRoot, TopicID: out.Items[i].TopicID}) + if err != nil { + return TopicPage{Items: []TopicRecord{}, Revision: out.Revision}, err + } + out.Items[i].Sessions = sessions + } + if more && len(out.Items) > 0 { + last := out.Items[len(out.Items)-1] + pinned := 0 + if last.Pinned { + pinned = 1 + } + out.NextCursor = encodeCursor(pageCursor{Pinned: pinned, Activity: last.LastActivityAt, TopicID: last.TopicID}) + } + return out, nil +} + +func (c *Catalog) listTopicSessions(ctx context.Context, key TopicKey) ([]SessionRecord, error) { + rows, err := c.db.QueryContext(ctx, `SELECT 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 FROM catalog_sessions + WHERE scope=? AND workspace_root=? AND topic_id=? + ORDER BY last_activity_at DESC,path ASC`, key.Scope, key.WorkspaceRoot, key.TopicID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []SessionRecord{} + for rows.Next() { + var record SessionRecord + if err := rows.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); err != nil { + return nil, err + } + out = append(out, record) + } + return out, rows.Err() +} + +func (c *Catalog) GetTopic(ctx context.Context, key TopicKey) (TopicRecord, bool, error) { + key.Scope, key.WorkspaceRoot = normalizeScope(key.Scope, key.WorkspaceRoot) + key.TopicID = strings.TrimSpace(key.TopicID) + item := TopicRecord{Sessions: []SessionRecord{}} + err := c.db.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id,title,pinned,sort_order, + turns,turns_state,created_at,last_activity_at,recovery_state,health + FROM catalog_topics WHERE scope=? AND workspace_root=? AND topic_id=?`, + key.Scope, key.WorkspaceRoot, key.TopicID).Scan( + &item.Scope, &item.WorkspaceRoot, &item.TopicID, &item.Title, + &item.Pinned, &item.SortOrder, &item.Turns, &item.TurnsState, + &item.CreatedAt, &item.LastActivityAt, &item.RecoveryState, &item.Health) + if errors.Is(err, sql.ErrNoRows) { + return item, false, nil + } + if err != nil { + return item, false, err + } + item.Sessions, err = c.listTopicSessions(ctx, key) + if err != nil { + return TopicRecord{Sessions: []SessionRecord{}}, false, err + } + return item, true, nil +} + +func encodeCursor(cursor pageCursor) string { + b, _ := json.Marshal(cursor) + return base64.RawURLEncoding.EncodeToString(b) +} + +func decodeCursor(encoded string) (*pageCursor, 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 pageCursor + if err := json.Unmarshal(b, &cursor); err != nil || cursor.TopicID == "" { + return nil, errors.New("invalid session catalog cursor") + } + return &cursor, nil +} + +func timeFilterCutoff(filter string, now time.Time) int64 { + var duration time.Duration + value := strings.TrimSpace(strings.ToLower(filter)) + switch value { + case "day", "24h": + duration = 24 * time.Hour + case "week", "7d": + duration = 7 * 24 * time.Hour + case "month", "30d": + duration = 30 * 24 * time.Hour + default: + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return 0 + } + duration = parsed + } + return now.Add(-duration).UnixMilli() +} + +func (c *Catalog) Close(ctx context.Context) error { + if c == nil { + return nil + } + c.stopOnce.Do(func() { + if c.workerCancel != nil { + c.workerCancel() + } + close(c.stop) + }) + done := make(chan struct{}) + go func() { + c.workers.Wait() + close(done) + }() + select { + case <-done: + var err error + c.closeDBOnce.Do(func() { err = c.db.Close() }) + c.statusMu.Lock() + c.status.State = StateClosed + c.statusMu.Unlock() + return err + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/internal/sessioncatalog/catalog_test.go b/internal/sessioncatalog/catalog_test.go new file mode 100644 index 0000000000..6ea7a0ace1 --- /dev/null +++ b/internal/sessioncatalog/catalog_test.go @@ -0,0 +1,488 @@ +package sessioncatalog + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "reasonix/internal/agent" +) + +func TestReconcileMakesUnknownCountsVisibleWithoutReadingTranscript(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + path := filepath.Join(dir, "legacy.jsonl") + if err := os.WriteFile(path, []byte("not valid jsonl\n"), 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) + if err := agent.SaveBranchMeta(path, agent.BranchMeta{ + Scope: "project", + WorkspaceRoot: "/workspace", + TopicID: "topic-1", + TopicTitle: "Legacy topic", + SchemaVersion: 1, + Turns: 0, + }); err != nil { + t.Fatal(err) + } + + 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()) }) + + if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{ + Path: dir, + Scope: "project", + WorkspaceRoot: "/workspace", + }); err != nil { + t.Fatal(err) + } + page, err := catalog.ListTopics(ctx, TopicPageRequest{ + Scope: "project", + WorkspaceRoot: "/workspace", + Limit: 50, + }) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || len(page.Items[0].Sessions) != 1 { + t.Fatalf("page = %#v, want one visible topic/session", page) + } + if got := page.Items[0].Sessions[0].TurnsState; got != TurnsUnknown { + t.Fatalf("turns state = %q, want %q", got, TurnsUnknown) + } +} + +func TestListTopicsUsesStableKeysetCursor(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()) }) + + base := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC) + for i, topicID := range []string{"a", "b", "c"} { + if err := catalog.UpsertSession(ctx, SessionRecord{ + Path: filepath.Join("/sessions", topicID+".jsonl"), + Directory: "/sessions", + Scope: "global", + TopicID: topicID, + TopicTitle: topicID, + LastActivityAt: base.Add(time.Duration(i) * time.Minute).UnixMilli(), + Turns: i + 1, + TurnsState: TurnsValid, + Health: HealthOK, + }); err != nil { + t.Fatal(err) + } + } + first, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 2}) + if err != nil { + t.Fatal(err) + } + if len(first.Items) != 2 || first.NextCursor == "" { + t.Fatalf("first page = %#v", first) + } + second, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 2, Cursor: first.NextCursor}) + if err != nil { + t.Fatal(err) + } + if len(second.Items) != 1 || second.NextCursor != "" { + t.Fatalf("second page = %#v", second) + } + if first.Items[0].TopicID != "c" || first.Items[1].TopicID != "b" || second.Items[0].TopicID != "a" { + t.Fatalf("keyset order = %q, %q, %q", first.Items[0].TopicID, first.Items[1].TopicID, second.Items[0].TopicID) + } +} + +func TestGetTopicIsNotLimitedByPageSize(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 := 0; i <= MaxLimit; i++ { + topicID := fmt.Sprintf("topic-%03d", i) + if err := catalog.UpsertSession(ctx, SessionRecord{ + Path: filepath.Join("/sessions", topicID+".jsonl"), Directory: "/sessions", + Scope: "global", TopicID: topicID, TopicTitle: topicID, + LastActivityAt: int64(MaxLimit - i), TurnsState: TurnsValid, Health: HealthOK, + }); err != nil { + t.Fatal(err) + } + } + + topic, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "topic-200"}) + if err != nil { + t.Fatal(err) + } + if !ok || topic.TopicID != "topic-200" || len(topic.Sessions) != 1 { + t.Fatalf("topic = %#v, ok=%v", topic, ok) + } +} + +func TestUnchangedDirectorySignatureSkipsReconcileRevision(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + path := filepath.Join(dir, "session.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := agent.SaveBranchMeta(path, agent.BranchMeta{ + Scope: "global", TopicID: "topic", TopicTitle: "Topic", + SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1, + }); err != nil { + t.Fatal(err) + } + 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()) }) + target := DirectoryTarget{Path: dir, Scope: "global"} + if err := catalog.ReconcileDirectory(ctx, target); err != nil { + t.Fatal(err) + } + revision := catalog.Status().Revision + if err := catalog.ReconcileDirectory(ctx, target); err != nil { + t.Fatal(err) + } + if got := catalog.Status().Revision; got != revision { + t.Fatalf("unchanged scan bumped revision: got %d want %d", got, revision) + } +} + +func TestSyncMetadataRemovesOnlyMetadataOnlyTopics(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()) }) + if err := catalog.SyncMetadata(ctx, nil, []TopicMetadata{ + {Scope: "global", TopicID: "metadata-only", Title: "Metadata"}, + {Scope: "global", TopicID: "with-session", Title: "Session"}, + }); err != nil { + t.Fatal(err) + } + if err := catalog.UpsertSession(ctx, SessionRecord{ + Path: "/sessions/with-session.jsonl", Directory: "/sessions", Scope: "global", + TopicID: "with-session", Turns: 1, TurnsState: TurnsValid, Health: HealthOK, + }); err != nil { + t.Fatal(err) + } + if err := catalog.SyncMetadata(ctx, nil, nil); err != nil { + t.Fatal(err) + } + if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "metadata-only"}); err != nil || ok { + t.Fatalf("metadata-only topic survived removal: ok=%v err=%v", ok, err) + } + if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "with-session"}); err != nil || !ok { + t.Fatalf("session-derived topic was removed: ok=%v err=%v", ok, err) + } +} + +func TestSchemaMigrationLedgerRecordsEveryVersion(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()) }) + rows, err := catalog.db.QueryContext(ctx, `SELECT version FROM schema_migrations ORDER BY version`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + versions := []int{} + for rows.Next() { + var version int + if err := rows.Scan(&version); err != nil { + t.Fatal(err) + } + versions = append(versions, version) + } + if fmt.Sprint(versions) != "[1 2]" { + t.Fatalf("schema migration ledger = %v", versions) + } +} + +func TestDirectWriteDuringScanIsNotMarkedMissing(t *testing.T) { + t.Parallel() + ctx := context.Background() + now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC) + dir := t.TempDir() + catalog, err := Open(ctx, Options{ + Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + target := DirectoryTarget{Path: dir, Scope: "global"} + generation, err := catalog.beginDirectoryScan(ctx, target, now.UnixMilli()) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "late.jsonl") + if err := catalog.UpsertSession(ctx, SessionRecord{ + Path: path, Directory: dir, Scope: "global", TopicID: "late", + TurnsState: TurnsUnknown, Health: HealthOK, + }); err != nil { + t.Fatal(err) + } + if err := catalog.finishDirectoryScan(ctx, target, "test", generation, now.UnixMilli(), 0); err != nil { + t.Fatal(err) + } + topic, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "late"}) + if err != nil || !ok || len(topic.Sessions) != 1 || topic.Sessions[0].Health != HealthOK { + t.Fatalf("late write was marked missing: topic=%#v ok=%v err=%v", topic, ok, err) + } +} + +func TestSessionTopicMoveRecomputesOldTopic(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()) }) + record := SessionRecord{ + Path: "/sessions/moved.jsonl", Directory: "/sessions", Scope: "global", + TopicID: "old", Turns: 1, TurnsState: TurnsValid, Health: HealthOK, + } + if err := catalog.UpsertSession(ctx, record); err != nil { + t.Fatal(err) + } + record.TopicID = "new" + if err := catalog.UpsertSession(ctx, record); err != nil { + t.Fatal(err) + } + if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "old"}); err != nil || ok { + t.Fatalf("old topic survived move: ok=%v err=%v", ok, err) + } + if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "new"}); err != nil || !ok { + t.Fatalf("new topic missing after move: ok=%v err=%v", ok, err) + } +} + +func TestWriterQueueCoalescesBySessionPath(t *testing.T) { + t.Parallel() + ctx := context.Background() + catalog, err := Open(ctx, Options{ + Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true, QueueCapacity: 1, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + for turns := 1; turns <= 100; turns++ { + if ok := catalog.EnqueueSession(SessionRecord{ + Path: "/sessions/coalesced.jsonl", Directory: "/sessions", Scope: "global", + TopicID: "coalesced", Turns: turns, TurnsState: TurnsValid, Health: HealthOK, + }); !ok { + t.Fatalf("same-path update %d was rejected by a one-slot queue", turns) + } + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + topic, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "coalesced"}) + if err != nil { + t.Fatal(err) + } + if ok && topic.Turns == 100 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("coalesced writer did not persist the latest record") +} + +func TestRemoveSessionWinsOverQueuedStaleWriteAndAllowsLaterRecreation(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + path := filepath.Join(dir, "session.jsonl") + 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()) }) + record := SessionRecord{ + Path: path, Directory: dir, Scope: "global", TopicID: "topic", + Turns: 1, TurnsState: TurnsValid, Health: HealthOK, + } + if err := catalog.UpsertSession(ctx, record); err != nil { + t.Fatal(err) + } + record.Turns = 99 + if !catalog.EnqueueSession(record) { + t.Fatal("queue stale write") + } + if err := catalog.RemoveSession(ctx, path, "test_remove"); err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + if _, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "topic"}); err != nil || ok { + t.Fatalf("queued write resurrected removed row: ok=%v err=%v", ok, err) + } + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := agent.SaveBranchMeta(path, agent.BranchMeta{ + Scope: "global", TopicID: "topic", SchemaVersion: agent.BranchMetaCountsVersion, Turns: 2, + }); err != nil { + t.Fatal(err) + } + if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil { + t.Fatal(err) + } + if topic, ok, err := catalog.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "topic"}); err != nil || !ok || topic.Turns != 2 { + t.Fatalf("new external file did not supersede removal: topic=%#v ok=%v err=%v", topic, ok, err) + } +} + +func TestCloseCancelsCatalogWorkerContext(t *testing.T) { + catalog, err := Open(context.Background(), Options{ + Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true, + }) + if err != nil { + t.Fatal(err) + } + workerDone := catalog.workerCtx.Done() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := catalog.Close(ctx); err != nil { + t.Fatal(err) + } + select { + case <-workerDone: + default: + t.Fatal("catalog close left worker context active") + } +} + +func BenchmarkListTopicsWarmCatalog10K(b *testing.B) { + ctx := context.Background() + catalog, err := Open(ctx, Options{ + Path: filepath.Join(b.TempDir(), "catalog.sqlite"), DisableRepair: true, + }) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = catalog.Close(context.Background()) }) + records := make([]SessionRecord, 10_000) + for i := range records { + records[i] = SessionRecord{ + Path: filepath.Join("/sessions", fmt.Sprintf("%05d.jsonl", i)), Directory: "/sessions", + Scope: "project", WorkspaceRoot: "/workspace", TopicID: fmt.Sprintf("topic-%05d", i), + Preview: fmt.Sprintf("synthetic session %05d", i), Turns: i%20 + 1, + TurnsState: TurnsValid, Health: HealthOK, LastActivityAt: int64(i + 1), + } + } + for start := 0; start < len(records); start += 64 { + end := min(start+64, len(records)) + if err := catalog.upsertSessions(ctx, records[start:end], nil, "benchmark-setup"); err != nil { + b.Fatal(err) + } + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + page, err := catalog.ListTopics(ctx, TopicPageRequest{ + Scope: "project", WorkspaceRoot: "/workspace", Limit: 50, + }) + if err != nil || len(page.Items) != 50 { + b.Fatalf("page len=%d err=%v", len(page.Items), err) + } + } +} + +func TestMissingSessionRequiresTwoScansAndGraceBeforeRemoval(t *testing.T) { + t.Parallel() + ctx := context.Background() + now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC) + dir := t.TempDir() + path := filepath.Join(dir, "session.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := agent.SaveBranchMeta(path, agent.BranchMeta{ + Scope: "global", TopicID: "topic", TopicTitle: "Topic", + SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1, + }); err != nil { + t.Fatal(err) + } + catalog, err := Open(ctx, Options{ + Path: filepath.Join(t.TempDir(), "catalog.sqlite"), + DisableRepair: true, + MissingGrace: time.Minute, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil { + t.Fatal(err) + } + if page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50}); err != nil || len(page.Items) != 1 { + t.Fatalf("first missing scan removed row: page=%#v err=%v", page, err) + } + now = now.Add(2 * time.Minute) + if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil { + t.Fatal(err) + } + if page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50}); err != nil || len(page.Items) != 0 { + t.Fatalf("stale row survived second scan after grace: page=%#v err=%v", page, err) + } +} + +func TestOpenQuarantinesCorruptCatalogAndRebuildsProjection(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + path := filepath.Join(dir, "catalog.sqlite") + if err := os.WriteFile(path, []byte("not sqlite"), 0o600); err != nil { + t.Fatal(err) + } + catalog, err := Open(ctx, Options{Path: path, DisableRepair: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + status := catalog.Status() + if status.State != StateReady || status.QuarantinedPath == "" { + t.Fatalf("status = %#v, want ready catalog with quarantined path", status) + } + if _, err := os.Stat(status.QuarantinedPath); err != nil { + t.Fatalf("quarantined catalog: %v", err) + } +} diff --git a/internal/sessioncatalog/metadata.go b/internal/sessioncatalog/metadata.go new file mode 100644 index 0000000000..ace4c53cac --- /dev/null +++ b/internal/sessioncatalog/metadata.go @@ -0,0 +1,82 @@ +package sessioncatalog + +import ( + "context" + "strings" +) + +// SyncMetadata projects the small desktop project/topic registries. It never +// removes session-derived topics: an older CLI or a concurrently running +// Reasonix process may have written authoritative sidecars not yet reflected in +// desktop-projects.json. +func (c *Catalog) SyncMetadata(ctx context.Context, projects []ProjectRecord, topics []TopicMetadata) error { + if c == nil || c.db == nil { + return nil + } + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + roots := map[string]struct{}{} + if _, err := tx.ExecContext(ctx, `DELETE FROM catalog_projects`); err != nil { + _ = tx.Rollback() + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE catalog_topics SET metadata_present=0`); err != nil { + _ = tx.Rollback() + return err + } + for _, project := range projects { + project.Scope, project.WorkspaceRoot = normalizeScope(project.Scope, project.WorkspaceRoot) + if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_projects( + scope,workspace_root,title,color,pinned,sort_order,updated_at + ) VALUES(?,?,?,?,?,?,?) ON CONFLICT(scope,workspace_root) DO UPDATE SET + title=excluded.title,color=excluded.color,pinned=excluded.pinned, + sort_order=excluded.sort_order,updated_at=excluded.updated_at`, + project.Scope, project.WorkspaceRoot, project.Title, project.Color, + project.Pinned, project.SortOrder, c.opts.Now().UnixMilli()); err != nil { + _ = tx.Rollback() + return err + } + roots[project.WorkspaceRoot] = struct{}{} + } + for _, topic := range topics { + topic.Scope, topic.WorkspaceRoot = normalizeScope(topic.Scope, topic.WorkspaceRoot) + if strings.TrimSpace(topic.TopicID) == "" { + continue + } + if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_topics( + scope,workspace_root,topic_id,title,title_source,pinned,sort_order, + turns,turns_state,created_at,last_activity_at,recovery_state,health,metadata_present + ) VALUES(?,?,?,?,?,?,?,0,'valid',?,0,'','ok',1) + ON CONFLICT(scope,workspace_root,topic_id) DO UPDATE SET + title=CASE WHEN excluded.title<>'' THEN excluded.title ELSE catalog_topics.title END, + title_source=excluded.title_source,pinned=excluded.pinned, + sort_order=excluded.sort_order,metadata_present=1, + created_at=CASE WHEN excluded.created_at>0 THEN excluded.created_at ELSE catalog_topics.created_at END`, + topic.Scope, topic.WorkspaceRoot, topic.TopicID, topic.Title, + topic.TitleSource, topic.Pinned, topic.SortOrder, topic.CreatedAt); err != nil { + _ = tx.Rollback() + return err + } + roots[topic.WorkspaceRoot] = struct{}{} + } + if _, err := tx.ExecContext(ctx, `DELETE FROM catalog_topics + WHERE metadata_present=0 AND NOT EXISTS ( + SELECT 1 FROM catalog_sessions s WHERE s.scope=catalog_topics.scope + AND s.workspace_root=catalog_topics.workspace_root AND s.topic_id=catalog_topics.topic_id + )`); err != nil { + _ = tx.Rollback() + return err + } + revision, err := bumpRevision(ctx, tx) + if err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + c.publishRevision(revision, mapKeys(roots), "metadata") + return nil +} diff --git a/internal/sessioncatalog/reconcile.go b/internal/sessioncatalog/reconcile.go new file mode 100644 index 0000000000..6d02ec9ca5 --- /dev/null +++ b/internal/sessioncatalog/reconcile.go @@ -0,0 +1,616 @@ +package sessioncatalog + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "reasonix/internal/agent" +) + +func (c *Catalog) ReconcileDirectory(ctx context.Context, target DirectoryTarget) error { + if c == nil || c.db == nil { + return nil + } + target.Path = filepath.Clean(strings.TrimSpace(target.Path)) + if target.Path == "." || target.Path == "" { + return nil + } + lock := c.directoryLock(target.Path) + lock.Lock() + defer lock.Unlock() + target.Scope, target.WorkspaceRoot = normalizeScope(target.Scope, target.WorkspaceRoot) + signature, err := directorySignature(target.Path) + if err != nil { + c.failDirectoryScan(ctx, target.Path, err) + return err + } + if unchanged, err := c.directoryScanCanSkip(ctx, target.Path, signature); err != nil { + return err + } else if unchanged { + return nil + } + now := c.opts.Now().UnixMilli() + generation, err := c.beginDirectoryScan(ctx, target, now) + if err != nil { + return err + } + ordered, err := agent.ListSessionOrder(target.Path) + if err != nil { + c.failDirectoryScan(ctx, target.Path, err) + return err + } + for start := 0; start < len(ordered); start += 64 { + if err := ctx.Err(); err != nil { + c.failDirectoryScan(context.Background(), target.Path, err) + return err + } + end := min(start+64, len(ordered)) + records := make([]SessionRecord, 0, end-start) + generations := make(map[string]int64, end-start) + for _, info := range ordered[start:end] { + record := recordFromOrder(target, info) + // This scan started while holding the directory lock after any + // completed removal. A present file is therefore a newer external + // recreation and may supersede the in-memory removal tombstone. + c.removedPaths.Delete(record.Path) + records = append(records, record) + generations[record.Path] = generation + } + if err := c.upsertSessions(ctx, records, generations, "reconcile"); err != nil { + c.failDirectoryScan(context.Background(), target.Path, err) + return err + } + cursor := "" + if len(records) > 0 { + cursor = records[len(records)-1].Path + } + if _, err := c.db.ExecContext(ctx, `UPDATE catalog_directories SET scan_cursor=?,indexed=? WHERE path=?`, cursor, end, target.Path); err != nil { + return err + } + for _, record := range records { + if record.TurnsState == TurnsUnknown { + c.enqueueRepair(record.Path) + } + } + runtime.Gosched() + } + if err := c.finishDirectoryScan(ctx, target, signature, generation, now, len(ordered)); err != nil { + return err + } + c.refreshCounts(ctx) + return nil +} + +func directorySignature(dir string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return "missing", nil + } + return "", err + } + hash := sha256.New() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || (!strings.HasSuffix(name, ".jsonl") && !strings.HasSuffix(name, ".meta")) { + continue + } + info, err := entry.Info() + if err != nil { + return "", err + } + _, _ = fmt.Fprintf(hash, "%s\x00%d\x00%d\x00%d\n", name, info.Size(), info.ModTime().UnixNano(), info.Mode()) + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func (c *Catalog) directoryScanCanSkip(ctx context.Context, path, signature string) (bool, error) { + var previous, state string + err := c.db.QueryRowContext(ctx, `SELECT signature,state FROM catalog_directories WHERE path=?`, path).Scan(&previous, &state) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + if previous != signature || state != "ready" { + return false, nil + } + var missing int + if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE directory=? AND missing_since>0`, path).Scan(&missing); err != nil { + return false, err + } + return missing == 0, nil +} + +func (c *Catalog) directoryLock(path string) *sync.Mutex { + c.directoryLocksMu.Lock() + defer c.directoryLocksMu.Unlock() + lock := c.directoryLocks[path] + if lock == nil { + lock = &sync.Mutex{} + c.directoryLocks[path] = lock + } + return lock +} + +// RequestReconcile coalesces external writes by directory. Queue pressure is +// intentionally lossy: fingerprints on the next scan recover missed requests, +// while a session save never waits on catalog work. +func (c *Catalog) RequestReconcile(target DirectoryTarget) bool { + if c == nil || strings.TrimSpace(target.Path) == "" { + return false + } + target.Path = filepath.Clean(target.Path) + if _, loaded := c.reconcileQueued.LoadOrStore(target.Path, target); loaded { + return true + } + select { + case c.reconcileCh <- target: + return true + case <-c.stop: + c.reconcileQueued.Delete(target.Path) + return false + default: + c.reconcileQueued.Delete(target.Path) + return false + } +} + +func (c *Catalog) reconcileLoop() { + defer c.workers.Done() + for { + select { + case target := <-c.reconcileCh: + c.reconcileQueued.Delete(target.Path) + ctx, cancel := context.WithTimeout(c.workerCtx, 2*time.Minute) + _ = c.ReconcileDirectory(ctx, target) + cancel() + case <-c.stop: + return + } + } +} + +// RequestIndexSession coalesces an authoritative session write by path. It is +// intentionally non-blocking so a saturated projection can never delay JSONL +// or sidecar persistence. +func (c *Catalog) RequestIndexSession(target DirectoryTarget, path string) bool { + if c == nil { + return false + } + path = filepath.Clean(strings.TrimSpace(path)) + if path == "." || path == "" { + return false + } + target.Path = filepath.Clean(strings.TrimSpace(target.Path)) + if target.Path == "." || target.Path == "" { + target.Path = filepath.Dir(path) + } + request := sessionPathRequest{target: target, path: path} + if _, loaded := c.pathQueued.LoadOrStore(path, request); loaded { + return true + } + select { + case c.pathCh <- request: + return true + case <-c.stop: + c.pathQueued.Delete(path) + return false + default: + c.pathQueued.Delete(path) + return false + } +} + +func (c *Catalog) sessionPathLoop() { + defer c.workers.Done() + for { + select { + case request := <-c.pathCh: + c.pathQueued.Delete(request.path) + ctx, cancel := context.WithTimeout(c.workerCtx, 30*time.Second) + _ = c.IndexSessionPath(ctx, request.target, request.path) + cancel() + case <-c.stop: + return + } + } +} + +// IndexSessionPath prioritizes a restored tab's exact session without walking +// its directory. This keeps cold-start recovery independent from the size of +// the history store. +func (c *Catalog) IndexSessionPath(ctx context.Context, target DirectoryTarget, path string) error { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "." || path == "" { + return nil + } + c.removedPaths.Delete(path) + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + meta, ok, err := agent.LoadBranchMeta(path) + if err != nil { + return err + } + order := agent.SessionOrderInfo{ + Path: path, + CreatedAt: info.ModTime(), + LastActivityAt: info.ModTime(), + ModTime: info.ModTime(), + Scope: target.Scope, + WorkspaceRoot: target.WorkspaceRoot, + } + if ok { + order.CreatedAt = meta.CreatedAt + order.LastActivityAt = meta.UpdatedAt + order.ModTime = meta.UpdatedAt + order.Scope = meta.DefaultScope() + order.WorkspaceRoot = meta.WorkspaceRoot + order.TopicID = meta.TopicID + order.TopicTitle = meta.TopicTitle + order.CustomTitle = meta.CustomTitle + order.Recovered = meta.Recovered + order.RecoveryReason = meta.RecoveryReason + order.RecoveryDigest = meta.RecoveryDigest + order.ParentID = meta.ParentID + order.Turns = meta.Turns + order.Preview = meta.Preview + order.SchemaVersion = meta.SchemaVersion + } + if order.CreatedAt.IsZero() { + order.CreatedAt = info.ModTime() + } + if order.LastActivityAt.IsZero() { + order.LastActivityAt = info.ModTime() + } + record := recordFromOrder(target, order) + if err := c.UpsertSession(ctx, record); err != nil { + return err + } + if record.TurnsState == TurnsUnknown { + c.enqueueRepair(record.Path) + } + return nil +} + +func recordFromOrder(target DirectoryTarget, info agent.SessionOrderInfo) SessionRecord { + scope, root := normalizeScope(info.Scope, info.WorkspaceRoot) + if info.TopicID == "" { + scope, root = target.Scope, target.WorkspaceRoot + } + turnsState := TurnsValid + if info.SchemaVersion < agent.BranchMetaCountsVersion && info.Turns == 0 { + turnsState = TurnsUnknown + } + contentFingerprint := fileFingerprint(info.Path) + metaFingerprint := fileFingerprint(agent.BranchMetaPath(info.Path)) + return normalizeSessionRecord(SessionRecord{ + Path: info.Path, + Directory: target.Path, + Scope: scope, + WorkspaceRoot: root, + TopicID: info.TopicID, + TopicTitle: info.TopicTitle, + CustomTitle: info.CustomTitle, + CreatedAt: unixMilli(info.CreatedAt), + LastActivityAt: unixMilli(info.LastActivityAt), + Preview: info.Preview, + Turns: info.Turns, + TurnsState: turnsState, + Recovered: info.Recovered, + RecoveryReason: info.RecoveryReason, + RecoveryDigest: info.RecoveryDigest, + ParentID: info.ParentID, + ContentFingerprint: contentFingerprint, + MetaFingerprint: metaFingerprint, + Health: HealthOK, + }) +} + +func unixMilli(value time.Time) int64 { + if value.IsZero() { + return 0 + } + return value.UnixMilli() +} + +func fileFingerprint(path string) string { + info, err := os.Stat(path) + if err != nil { + return "" + } + return fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano()) +} + +func (c *Catalog) beginDirectoryScan(ctx context.Context, target DirectoryTarget, now int64) (int64, error) { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_directories(path,scope,workspace_root,state,error) + VALUES(?,?,?,'scanning','') ON CONFLICT(path) DO UPDATE SET + scope=excluded.scope,workspace_root=excluded.workspace_root,state='scanning',error='', + scan_generation=catalog_directories.scan_generation+1,scan_cursor='',indexed=0`, + target.Path, target.Scope, target.WorkspaceRoot); err != nil { + _ = tx.Rollback() + return 0, err + } + var generation int64 + if err := tx.QueryRowContext(ctx, `SELECT scan_generation FROM catalog_directories WHERE path=?`, target.Path).Scan(&generation); err != nil { + _ = tx.Rollback() + return 0, err + } + if generation == 0 { + generation = 1 + if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET scan_generation=1 WHERE path=?`, target.Path); err != nil { + _ = tx.Rollback() + return 0, err + } + } + return generation, tx.Commit() +} + +func (c *Catalog) finishDirectoryScan(ctx context.Context, target DirectoryTarget, signature string, generation, now int64, total int) error { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + rows, err := tx.QueryContext(ctx, `SELECT scope,workspace_root,topic_id FROM catalog_sessions + WHERE directory=? AND seen_generation''`, target.Path, generation) + if err != nil { + _ = tx.Rollback() + return err + } + affected := map[TopicKey]struct{}{} + for rows.Next() { + var key TopicKey + if err := rows.Scan(&key.Scope, &key.WorkspaceRoot, &key.TopicID); err != nil { + _ = rows.Close() + _ = tx.Rollback() + return err + } + affected[key] = struct{}{} + } + if err := rows.Close(); err != nil { + _ = tx.Rollback() + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE catalog_sessions SET + missing_since=CASE WHEN missing_since=0 THEN ? ELSE missing_since END, + health='missing' + WHERE directory=? AND seen_generation0 AND missing_since<=?`, target.Path, generation, cutoff); err != nil { + _ = tx.Rollback() + return err + } + for key := range affected { + if err := recomputeTopic(ctx, tx, key); err != nil { + _ = tx.Rollback() + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET state='ready',error='',signature=?, + scan_cursor='',indexed=?,total=?,completed_at=? WHERE path=?`, signature, total, total, now, target.Path); err != nil { + _ = tx.Rollback() + return err + } + revision, err := bumpRevision(ctx, tx) + if err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + c.publishRevision(revision, []string{target.WorkspaceRoot}, "reconcile_complete") + c.refreshCounts(ctx) + c.statusMu.Lock() + c.status.State = StateReady + c.status.LastError = "" + c.statusMu.Unlock() + return nil +} + +func (c *Catalog) failDirectoryScan(ctx context.Context, path string, scanErr error) { + _, _ = c.db.ExecContext(ctx, `UPDATE catalog_directories SET state='degraded',error=? WHERE path=?`, scanErr.Error(), path) + c.statusMu.Lock() + c.status.State = StateDegraded + c.status.LastError = scanErr.Error() + c.statusMu.Unlock() +} + +func (c *Catalog) enqueueRepair(path string) { + if c == nil || c.opts.DisableRepair || strings.TrimSpace(path) == "" { + return + } + if _, loaded := c.repairQueued.LoadOrStore(path, struct{}{}); loaded { + return + } + select { + case c.repairCh <- path: + case <-c.stop: + c.repairQueued.Delete(path) + default: + c.repairQueued.Delete(path) + } +} + +func (c *Catalog) enqueuePersistedRepairs(ctx context.Context) { + rows, err := c.db.QueryContext(ctx, `SELECT path FROM catalog_sessions WHERE turns_state='unknown' ORDER BY last_activity_at DESC`) + if err != nil { + return + } + defer rows.Close() + for rows.Next() { + var path string + if rows.Scan(&path) == nil { + c.enqueueRepair(path) + } + } +} + +func (c *Catalog) repairLoop() { + defer c.workers.Done() + for { + select { + case path := <-c.repairCh: + c.repairSession(c.workerCtx, path) + c.repairQueued.Delete(path) + runtime.Gosched() + case <-c.stop: + return + } + } +} + +func (c *Catalog) repairSession(workerCtx context.Context, path string) { + ctx, cancel := context.WithTimeout(workerCtx, 30*time.Second) + defer cancel() + msgs, _, _, err := agent.LoadSessionDisplayMessages(path) + if ctx.Err() != nil { + return + } + if err != nil { + _, _ = c.db.ExecContext(ctx, `UPDATE catalog_sessions SET turns_state='corrupt',health='corrupt' WHERE path=?`, path) + _ = c.recomputeTopicForSession(ctx, path, "repair_corrupt") + return + } + preview, turns := agent.SessionPreviewFromMessages(msgs) + if err := agent.UpdateBranchMeta(path, false, func(meta *agent.BranchMeta) error { + meta.Preview = preview + meta.Turns = turns + meta.SchemaVersion = agent.BranchMetaCountsVersion + return nil + }); err != nil { + return + } + _, _ = c.db.ExecContext(ctx, `UPDATE catalog_sessions SET preview=?,turns=?,turns_state='valid', + health='ok',meta_fingerprint=? WHERE path=?`, preview, turns, fileFingerprint(agent.BranchMetaPath(path)), path) + _ = c.recomputeTopicForSession(ctx, path, "repair") +} + +func (c *Catalog) recomputeTopicForSession(ctx context.Context, path, reason string) error { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + var key TopicKey + if err := tx.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id FROM catalog_sessions WHERE path=?`, path).Scan(&key.Scope, &key.WorkspaceRoot, &key.TopicID); err != nil { + _ = tx.Rollback() + if err == sql.ErrNoRows { + return nil + } + return err + } + if key.TopicID != "" { + if err := recomputeTopic(ctx, tx, key); err != nil { + _ = tx.Rollback() + return err + } + } + revision, err := bumpRevision(ctx, tx) + if err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + c.publishRevision(revision, []string{key.WorkspaceRoot}, reason) + c.refreshCounts(ctx) + return nil +} + +// Rebuild replaces only the disposable catalog. Authoritative sessions and +// sidecars are never changed or removed by this operation. +func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Status, error) { + if strings.TrimSpace(path) == "" { + path = DefaultPath() + } + backup := path + ".reindex-backup" + _ = os.Remove(backup) + if err := os.Rename(path, backup); err != nil && !os.IsNotExist(err) { + return Status{}, err + } + for _, suffix := range []string{"-wal", "-shm"} { + _ = os.Rename(path+suffix, backup+suffix) + } + catalog, err := Open(ctx, Options{Path: path, DisableRepair: true}) + if err != nil { + _ = os.Rename(backup, path) + return Status{}, err + } + for _, target := range targets { + if err := catalog.ReconcileDirectory(ctx, target); err != nil { + closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) + _ = catalog.Close(closeCtx) + cancel() + return catalog.Status(), err + } + } + status := catalog.Status() + closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) + _ = catalog.Close(closeCtx) + cancel() + _ = os.Remove(backup) + _ = os.Remove(backup + "-wal") + _ = os.Remove(backup + "-shm") + return status, nil +} + +// Inspect is read-only. It never migrates, repairs, quarantines, or rewrites a +// catalog, making it suitable for `reasonix doctor sessions`. +func Inspect(ctx context.Context, path string) (Status, error) { + if strings.TrimSpace(path) == "" { + path = DefaultPath() + } + status := Status{State: StateDegraded, Mode: ModeDisk, Path: path} + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + status.LastError = "catalog does not exist" + return status, nil + } + return status, err + } + u := &url.URL{Scheme: "file", Path: path} + db, err := sql.Open("sqlite", u.String()+"?mode=ro&_pragma=busy_timeout%28150%29") + if err != nil { + return status, err + } + defer db.Close() + var integrity string + if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil { + return status, err + } + if integrity != "ok" { + status.LastError = integrity + return status, nil + } + status.State = StateReady + _ = db.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&status.Revision) + _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions`).Scan(&status.Indexed) + _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown'`).Scan(&status.RepairPending) + return status, nil +} diff --git a/internal/sessioncatalog/remote_fs_darwin.go b/internal/sessioncatalog/remote_fs_darwin.go new file mode 100644 index 0000000000..50d90805ba --- /dev/null +++ b/internal/sessioncatalog/remote_fs_darwin.go @@ -0,0 +1,28 @@ +//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 new file mode 100644 index 0000000000..f4eb51c22e --- /dev/null +++ b/internal/sessioncatalog/remote_fs_linux.go @@ -0,0 +1,27 @@ +//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 new file mode 100644 index 0000000000..316cf362fd --- /dev/null +++ b/internal/sessioncatalog/remote_fs_other.go @@ -0,0 +1,5 @@ +//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 new file mode 100644 index 0000000000..aa1781f5dc --- /dev/null +++ b/internal/sessioncatalog/remote_fs_windows.go @@ -0,0 +1,26 @@ +//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 new file mode 100644 index 0000000000..0eb3f777ff --- /dev/null +++ b/internal/sessioncatalog/schema.go @@ -0,0 +1,149 @@ +package sessioncatalog + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +const migrationV1 = ` +CREATE TABLE IF NOT EXISTS catalog_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL DEFAULT 0 +); +INSERT OR IGNORE INTO catalog_state(id, revision) VALUES(1, 0); + +CREATE TABLE IF NOT EXISTS catalog_directories ( + path TEXT PRIMARY KEY, + scope TEXT NOT NULL, + workspace_root TEXT NOT NULL DEFAULT '', + signature TEXT NOT NULL DEFAULT '', + scan_cursor TEXT NOT NULL DEFAULT '', + scan_generation INTEGER NOT NULL DEFAULT 0, + 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 IF NOT EXISTS catalog_projects ( + scope TEXT NOT NULL, + workspace_root TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + pinned INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(scope, workspace_root) +); + +CREATE TABLE IF NOT EXISTS catalog_topics ( + scope TEXT NOT NULL, + workspace_root TEXT NOT NULL DEFAULT '', + topic_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + title_source TEXT NOT NULL DEFAULT '', + pinned INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + turns INTEGER NOT NULL DEFAULT 0, + turns_state TEXT NOT NULL DEFAULT 'unknown', + created_at INTEGER NOT NULL DEFAULT 0, + last_activity_at INTEGER NOT NULL DEFAULT 0, + recovery_state TEXT NOT NULL DEFAULT '', + health TEXT NOT NULL DEFAULT 'ok', + PRIMARY KEY(scope, workspace_root, topic_id) +); + +CREATE TABLE IF NOT EXISTS catalog_sessions ( + path TEXT PRIMARY KEY, + directory TEXT NOT NULL, + scope TEXT NOT NULL, + workspace_root TEXT NOT NULL DEFAULT '', + topic_id TEXT NOT NULL DEFAULT '', + topic_title TEXT NOT NULL DEFAULT '', + custom_title TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT 0, + last_activity_at INTEGER NOT NULL DEFAULT 0, + preview TEXT NOT NULL DEFAULT '', + turns INTEGER NOT NULL DEFAULT 0, + turns_state TEXT NOT NULL DEFAULT 'unknown', + recovered INTEGER NOT NULL DEFAULT 0, + recovery_reason TEXT NOT NULL DEFAULT '', + recovery_digest TEXT NOT NULL DEFAULT '', + parent_id TEXT NOT NULL DEFAULT '', + content_fingerprint TEXT NOT NULL DEFAULT '', + meta_fingerprint TEXT NOT NULL DEFAULT '', + health TEXT NOT NULL DEFAULT 'ok', + missing_since INTEGER NOT NULL DEFAULT 0, + seen_generation INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_catalog_topics_page + ON catalog_topics(scope, workspace_root, pinned DESC, last_activity_at DESC, topic_id ASC); +CREATE INDEX IF NOT EXISTS idx_catalog_topics_activity + ON catalog_topics(last_activity_at DESC, topic_id ASC); +CREATE INDEX IF NOT EXISTS idx_catalog_sessions_topic + ON catalog_sessions(scope, workspace_root, topic_id, last_activity_at DESC, path ASC); +CREATE INDEX IF NOT EXISTS idx_catalog_sessions_directory + ON catalog_sessions(directory, seen_generation, missing_since); +CREATE INDEX IF NOT EXISTS idx_catalog_sessions_path + ON catalog_sessions(path); +` + +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 { + 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 +} diff --git a/internal/sessioncatalog/types.go b/internal/sessioncatalog/types.go new file mode 100644 index 0000000000..e34acda2bd --- /dev/null +++ b/internal/sessioncatalog/types.go @@ -0,0 +1,163 @@ +// Package sessioncatalog maintains a disposable SQLite projection of Reasonix +// session sidecars. Session JSONL/event/meta files remain authoritative; every +// row in this package may be discarded and rebuilt. +package sessioncatalog + +import ( + "path/filepath" + "time" + + "reasonix/internal/config" +) + +const ( + SchemaVersion = 2 + DefaultLimit = 50 + MaxLimit = 200 +) + +type TurnsState string + +const ( + TurnsUnknown TurnsState = "unknown" + TurnsValid TurnsState = "valid" + TurnsCorrupt TurnsState = "corrupt" +) + +type Health string + +const ( + HealthOK Health = "ok" + HealthMissing Health = "missing" + HealthCorrupt Health = "corrupt" + HealthDegraded Health = "degraded" +) + +type State string + +const ( + StateOpening State = "opening" + StateReady State = "ready" + StateDegraded State = "degraded" + StateRebuilding State = "rebuilding" + StateClosed State = "closed" +) + +type Mode string + +const ( + ModeDisk Mode = "disk" + ModeMemory Mode = "memory" +) + +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"` + RepairPending int64 `json:"repairPending"` + LastError string `json:"lastError,omitempty"` + QuarantinedPath string `json:"quarantinedPath,omitempty"` +} + +type Options struct { + Path string + InMemory bool + DisableRepair bool + MissingGrace time.Duration + QueueCapacity int + Now func() time.Time + OnRevision func(uint64, []string, string) +} + +type DirectoryTarget struct { + Path string `json:"path"` + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` +} + +type ProjectRecord struct { + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + Title string `json:"title"` + Color string `json:"color,omitempty"` + Pinned bool `json:"pinned,omitempty"` + SortOrder int `json:"sortOrder,omitempty"` +} + +type TopicMetadata struct { + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + TopicID string `json:"topicId"` + Title string `json:"title"` + TitleSource string `json:"titleSource,omitempty"` + Pinned bool `json:"pinned,omitempty"` + SortOrder int `json:"sortOrder,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` +} + +type SessionRecord struct { + Path string `json:"path"` + Directory string `json:"directory"` + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + TopicID string `json:"topicId,omitempty"` + TopicTitle string `json:"topicTitle,omitempty"` + CustomTitle string `json:"customTitle,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` + LastActivityAt int64 `json:"lastActivityAt,omitempty"` + Preview string `json:"preview,omitempty"` + Turns int `json:"turns"` + TurnsState TurnsState `json:"turnsState"` + Recovered bool `json:"recovered,omitempty"` + RecoveryReason string `json:"recoveryReason,omitempty"` + RecoveryDigest string `json:"recoveryDigest,omitempty"` + ParentID string `json:"parentId,omitempty"` + ContentFingerprint string `json:"contentFingerprint,omitempty"` + MetaFingerprint string `json:"metaFingerprint,omitempty"` + Health Health `json:"health"` + MissingSince int64 `json:"missingSince,omitempty"` +} + +type TopicKey struct { + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + TopicID string `json:"topicId"` +} + +type TopicRecord struct { + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + TopicID string `json:"topicId"` + Title string `json:"title"` + Pinned bool `json:"pinned,omitempty"` + SortOrder int `json:"sortOrder,omitempty"` + Turns int `json:"turns"` + TurnsState TurnsState `json:"turnsState"` + CreatedAt int64 `json:"createdAt,omitempty"` + LastActivityAt int64 `json:"lastActivityAt,omitempty"` + RecoveryState string `json:"recoveryState,omitempty"` + Health Health `json:"health"` + Sessions []SessionRecord `json:"sessions"` +} + +type TopicPageRequest struct { + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` + Query string `json:"query,omitempty"` + TimeFilter string `json:"timeFilter,omitempty"` +} + +type TopicPage struct { + Items []TopicRecord `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` + Revision uint64 `json:"revision"` +} + +func DefaultPath() string { + return filepath.Join(config.CacheDir(), "session-catalog", "v1.sqlite") +} From f882c708b5b57e47f6adc12e887c7dd23fe21745 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:41:41 +0800 Subject: [PATCH 2/3] refactor: satisfy session catalog ownership budgets Problem: The initial session catalog implementation exceeded incremental repository size and complexity budgets in several existing owner files, and Desktop CI reported stale helpers plus two modernization findings. Root cause: Catalog lifecycle, runtime projection, frontend topic presentation, mock bindings, plugin compatibility, and CLI wiring were colocated with already-large modules. Removing the eager project tree also left legacy runtime-tree helpers unused. Fix: - split catalog lifecycle from runtime projection and metadata mapping - extract frontend catalog contracts, mock bridge, topic presentation, and count formatting - isolate plugin compatibility, session projection, and CLI completion wiring - remove obsolete eager-tree helpers and address CI modernization findings - preserve behavior without changing repolint baselines Verification: - go run ./tools/repolint - golangci-lint v2.12.2: 0 issues - go test affected root packages - focused Desktop catalog and workspace regression tests - pnpm test:all - pnpm build --- desktop/app.go | 38 +- desktop/frontend/src/components/Composer.tsx | 27 +- .../frontend/src/components/ProjectTree.tsx | 281 +--------- desktop/frontend/src/lib/bridge.ts | 66 +-- desktop/frontend/src/lib/projectTreeTopic.ts | 276 ++++++++++ .../frontend/src/lib/sessionCatalogBridge.ts | 60 +++ .../src/lib/sessionCatalogPresentation.ts | 12 + .../frontend/src/lib/sessionCatalogTypes.ts | 67 +++ desktop/frontend/src/lib/types.ts | 59 +-- desktop/session_catalog.go | 474 +---------------- desktop/session_catalog_runtime.go | 478 ++++++++++++++++++ desktop/session_meta.go | 29 ++ desktop/tabs.go | 73 +-- internal/agent/save.go | 27 +- internal/agent/save_listing_projection.go | 28 + internal/cli/cli.go | 7 +- internal/cli/sessions_catalog.go | 7 + internal/cli/shell_completion.go | 28 +- internal/pluginpkg/installed_compat.go | 95 ++++ internal/pluginpkg/pluginpkg.go | 86 ---- 20 files changed, 1101 insertions(+), 1117 deletions(-) create mode 100644 desktop/frontend/src/lib/projectTreeTopic.ts create mode 100644 desktop/frontend/src/lib/sessionCatalogBridge.ts create mode 100644 desktop/frontend/src/lib/sessionCatalogPresentation.ts create mode 100644 desktop/frontend/src/lib/sessionCatalogTypes.ts create mode 100644 desktop/session_catalog_runtime.go create mode 100644 desktop/session_meta.go create mode 100644 internal/agent/save_listing_projection.go create mode 100644 internal/pluginpkg/installed_compat.go diff --git a/desktop/app.go b/desktop/app.go index 0a11882d97..485e53284f 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -975,9 +975,8 @@ func (a *App) shutdown(context.Context) { // Remote web window child: nothing to snapshot or stop locally. return } - // Freeze publication first, then cancel work that is deliberately outside - // the lifecycle barrier. A history scan, catalog repair, or plugin handshake - // must never make normal quit wait for its I/O. + // Freeze publication, then cancel off-barrier history, catalog, and plugin + // work so normal quit never waits for background I/O. a.shuttingDown.Store(true) a.cancelAllTabBuilds() a.stopSessionCatalog(250 * time.Millisecond) @@ -1221,11 +1220,8 @@ func (a *App) beginTabTurn(tabID string, reclaim bool, submissionID ...string) ( if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil { return nil, nil, a.workspaceNotReadyErr(tab) } - // A stale workspace binding may require a full controller/plugin rebuild. - // Complete that cancellable work before entering the lifecycle barrier so - // shutdown and runtime mutations never wait behind filesystem or handshake - // I/O. The admission re-check below protects the short publication-to-turn - // boundary. + // Rebuild stale workspace bindings before admission so shutdown never waits + // behind filesystem or plugin I/O. The later re-check protects publication. if err := a.ensureTabControllerWorkspace(tab); err != nil { return nil, nil, err } @@ -3236,32 +3232,6 @@ func (a *App) sessionDirForPath(path string) (string, string, error) { return "", "", fmt.Errorf("session path outside known session dirs: %s", path) } -func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta { - turnsState := "unknown" - if s.CountsKnown { - turnsState = "valid" - } - return SessionMeta{ - Path: s.Path, - Preview: s.Preview, - Title: title, - Turns: s.Turns, - TurnsState: turnsState, - CreatedAt: s.CreatedAt.UnixMilli(), - LastActivityAt: s.LastActivityAt.UnixMilli(), - ModTime: s.LastActivityAt.UnixMilli(), - DeletedAt: deletedAt, - Current: current, - Open: open, - Scope: s.Scope, - WorkspaceRoot: s.WorkspaceRoot, - TopicID: s.TopicID, - TopicTitle: s.TopicTitle, - Recovered: sessionInfoIsAutomaticRecovery(s), - RecoveryCopy: sessionInfoIsUnmodifiedRecoveryCopy(s, parentDir), - } -} - func applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) { if meta == nil { return diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index 3d23cfbb22..0ae14d630b 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -7,6 +7,7 @@ import { DedupIndex, sha256 } from "../lib/attachDedup"; import { app, onFilesDropped } from "../lib/bridge"; import { canUsePromptHistory, composerEnterAction, composerEscapeAction, composerMenuKeyAction, insertComposerNewline, isFnKeyEvent, isImeKeyEvent, promptHistoryDirectionFromEvent } from "../lib/composerKeyboard"; import { cacheGeneration, loadOlder } from "../lib/composerHistory"; +import { sessionTurnsLabel } from "../lib/sessionCatalogPresentation"; import { SPINNER_WORDS, useI18n, type Translator } from "../lib/i18n"; import { detectShortcutPlatform, formatShortcutCombo, isReservedComposerHistoryShortcut, matchesShortcut, useShortcutComboLabel } from "../lib/keyboardShortcuts"; import { fallbackCopyText } from "../lib/clipboard"; @@ -4119,23 +4120,19 @@ export function Composer({ ) : ( filteredPastChats.map((session, i) => { - // PR-C2: hover preview uses only the SessionMeta fields we - // already have on hand — no extra PreviewSession call, no - // backend round-trip, no read of the full transcript. - const turnsKnown = session.turnsState !== "unknown" && typeof session.turns === "number"; - const turnsPending = session.turnsState === "unknown"; + // Hover preview stays on SessionMeta and never reads the transcript. + const turnsLabel = sessionTurnsLabel(session, t); const ts = session.lastActivityAt || session.modTime || session.createdAt; const preview = truncatePreview(session.preview); const pathText = session.workspaceRoot || session.path; const tooltipLabel = - turnsKnown || turnsPending || ts || preview || pathText ? ( + turnsLabel || ts || preview || pathText ? (
{pastChatTitle(session)}
{preview &&
{preview}
} - {(turnsKnown || turnsPending || ts) && ( + {(turnsLabel || ts) && (
- {turnsPending && {t("history.indexing")}} - {turnsKnown && {t("composer.sessionTurns", { n: session.turns })}} + {turnsLabel && {turnsLabel}} {ts && · {fmtSessionTime(ts)}}
)} @@ -4155,11 +4152,7 @@ export function Composer({ {pastChatTitle(session)} - {turnsPending - ? ` (${t("history.indexing")})` - : turnsKnown - ? ` (${t("composer.sessionTurns", { n: session.turns })})` - : ""} + {turnsLabel ? ` (${turnsLabel})` : ""} @@ -4324,11 +4317,7 @@ export function Composer({ {ref.title} - {ref.turnsState === "unknown" - ? ` (${t("history.indexing")})` - : typeof ref.turns === "number" - ? ` (${t("composer.sessionTurns", { n: ref.turns })})` - : ""} + {sessionTurnsLabel(ref, t) ? ` (${sessionTurnsLabel(ref, t)})` : ""} diff --git a/desktop/frontend/src/components/ProjectTree.tsx b/desktop/frontend/src/components/ProjectTree.tsx index 9b82e2d219..16d7694a4b 100644 --- a/desktop/frontend/src/components/ProjectTree.tsx +++ b/desktop/frontend/src/components/ProjectTree.tsx @@ -8,10 +8,13 @@ import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as React import { Archive, ArrowDown, Pencil, Plus, Folder, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, Check, ListCollapse, ListRestart, MessageSquare, Clock, Pin, MoreHorizontal, Minimize2, Maximize2, GitBranch } from "lucide-react"; import { asArray } from "../lib/array"; import { useToast } from "../lib/toast"; -import { app, onProjectTreeChangedV2 } from "../lib/bridge"; -import type { ProjectNode, ProjectTopicStatus, SessionCatalogStatus } from "../lib/types"; +import { app } from "../lib/bridge"; +import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge"; +import { isRuntimeSessionNode, isTopicNode, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type ProjectTreeVariant } from "../lib/projectTreeTopic"; +export * from "../lib/projectTreeTopic"; +import type { ProjectNode, SessionCatalogStatus } from "../lib/types"; import { topicActivityTime } from "../lib/session"; -import { getLocale, useT, type DictKey, type Translator } from "../lib/i18n"; +import { useT, type Translator } from "../lib/i18n"; import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors"; import { useProjectTreeArchiveState } from "../lib/projectTreeArchive"; import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts"; @@ -20,8 +23,6 @@ import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type Cont import { Tooltip } from "./Tooltip"; import { WorktreeBadge } from "./WorktreeBadge"; -type ProjectTreeVariant = "classic" | "workbench" | "creation"; - interface ProjectTreeProps { activeScope?: string; activeWorkspaceRoot?: string; @@ -56,276 +57,6 @@ function projectNodeKey(node: ProjectNode, depth: number): string { return node.key || `${node.kind}-${node.root ?? ""}-${node.topicId ?? ""}-${depth}`; } -function isRuntimeSessionNode(node: ProjectNode): boolean { - return node.kind === "session" || node.kind === "global_session"; -} - -function isTopicNode(node: ProjectNode): boolean { - return node.kind === "topic" || node.kind === "global_topic"; -} - -export function projectTreeRevisionIsFresh(currentRevision: number, incomingRevision: number): boolean { - return incomingRevision >= currentRevision; -} - -export function mergeProjectTopicPage(current: ProjectNode[], incoming: ProjectNode[], append: boolean): ProjectNode[] { - if (!append) return [...incoming]; - const next = [...current]; - const positions = new Map(next.map((node, index) => [node.key, index])); - for (const node of incoming) { - const index = positions.get(node.key); - if (index === undefined) { - positions.set(node.key, next.length); - next.push(node); - } else { - next[index] = node; - } - } - return next; -} - -export function projectTreeEventAffectsFolder(project: ProjectNode, roots: string[]): boolean { - if (roots.length === 0) return true; - const root = project.kind === "global_folder" ? "" : project.root ?? ""; - return roots.includes(root); -} - -export type ProjectTreeTopicOpenRequest = { - scope: "global" | "project"; - workspaceRoot: string; - topicId: string; - sessionPath?: string; -}; - -export function projectTreeTopicOpenRequest(node: ProjectNode): ProjectTreeTopicOpenRequest | null { - if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return null; - const scope = node.kind === "global_topic" || node.kind === "global_session" ? "global" : "project"; - return { - scope, - workspaceRoot: scope === "global" ? "" : node.root ?? "", - topicId: node.topicId ?? "", - sessionPath: node.sessionPath, - }; -} - -type ProjectTreeTopicClickTarget = { - rowKey: string; - canRename: boolean; -}; - -type ProjectTreePendingTopicOpen = ProjectTreeTopicClickTarget & { - timer: ReturnType; -}; - -export function projectTreeShouldSuppressOpenForRename( - pending: ProjectTreeTopicClickTarget | null, - next: ProjectTreeTopicClickTarget, -): boolean { - return Boolean(pending && pending.rowKey === next.rowKey && pending.canRename && next.canRename); -} - -export type ProjectTreeFolderDisclosure = { - canExpand: boolean; - isOpen: boolean; - ariaExpanded?: boolean; - iconStackClassName: string; -}; - -// allowEmptyExpand lets classic folders open without children so the expanded -// state can host the "no sessions" placeholder row; other variants keep the -// original contract where empty folders are inert. -export function projectTreeFolderDisclosure(hasChildren: boolean, isExpanded: boolean, allowEmptyExpand = false): ProjectTreeFolderDisclosure { - const canExpand = hasChildren || allowEmptyExpand; - const isOpen = canExpand && isExpanded; - return { - canExpand, - isOpen, - ariaExpanded: canExpand ? isExpanded : undefined, - iconStackClassName: `project-tree__icon-stack${canExpand ? " project-tree__icon-stack--expandable" : ""}`, - }; -} - -function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string, activeSessionPath?: string): boolean { - if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false; - if (node.sessionPath) return Boolean(activeSessionPath && activeSessionPath === node.sessionPath); - if (activeSessionPath && asArray(node.children).some(isRuntimeSessionNode)) return false; - const scope = node.kind === "global_topic" ? "global" : "project"; - return ( - activeTopicId === node.topicId && - activeScope === scope && - (scope === "global" || activeWorkspaceRoot === node.root) - ); -} - -export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string { - const parts: string[] = []; - const turns = node.turns ?? 0; - if (node.turnsState === "unknown") parts.push(t("history.indexing")); - else if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns })); - const activityAt = node.lastActivityAt || node.createdAt || 0; - if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact)); - if (parts.length === 0) parts.push(t("projectTree.previously")); - return parts.join(" · "); -} - -// Model for the classic hover preview card: the row keeps a time-only meta -// line, so the card carries the full title, turns, exact date, and project. -export type ProjectTreeTopicHoverCard = { - title: string; - statusLabel: string; - metaLine: string; - exactTime: string; - projectLabel: string; -}; - -// Activity labels older than a week are already the calendar date (always the -// meta line's last part), so callers pairing the two keep a single copy. -export function projectTreeDedupedExactTime(metaLine: string, exactTime: string): string { - return exactTime && metaLine.endsWith(exactTime) ? "" : exactTime; -} - -export function projectTreeTopicHoverCardModel(node: ProjectNode, t: Translator, projectLabel: string): ProjectTreeTopicHoverCard { - const activityAt = node.lastActivityAt || node.createdAt || 0; - const metaLine = projectTreeTopicMetaLine(node, t); - const exactTime = activityAt ? topicActivityDateLabel(activityAt) : ""; - return { - title: (node.label || node.topicId || "Untitled").replace(/^●\s*/, ""), - statusLabel: topicStatusLabel(node, t), - metaLine, - exactTime: projectTreeDedupedExactTime(metaLine, exactTime), - projectLabel, - }; -} - -function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string { - return topicActivityAt(node) ? "" : t("projectTree.previously"); -} - -const topicStatusLabels: Record = { - thinking: "projectTree.status.thinking", - streaming: "projectTree.status.streaming", - waiting_confirmation: "projectTree.status.waitingConfirmation", - background_job: "projectTree.status.backgroundJob", - paused: "projectTree.status.paused", - error: "projectTree.status.error", -}; - -function normalizeTopicStatus(status?: string): ProjectTopicStatus | "" { - if (!status) return ""; - if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job" || status === "paused" || status === "error") { - return status; - } - return ""; -} - -function topicStatus(node: ProjectNode): ProjectTopicStatus | "" { - return normalizeTopicStatus(node.status) || (node.running ? "streaming" : ""); -} - -export function projectTreeTopicArchiveBlocked(node: ProjectNode): boolean { - if (asArray(node.children).some(projectTreeTopicArchiveBlocked)) return true; - const status = normalizeTopicStatus(node.status); - if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job") return true; - if (status === "paused" || status === "error") return false; - return Boolean(node.running); -} - -function topicStatusLabel(node: ProjectNode, t: Translator): string { - const status = topicStatus(node); - return status ? t(topicStatusLabels[status]) : ""; -} - -function topicActivityAt(node: ProjectNode): number { - return node.lastActivityAt || node.createdAt || 0; -} - -export function projectTreeReadActivityKey(node: ProjectNode): string | null { - const request = projectTreeTopicOpenRequest(node); - if (!request?.topicId) return null; - return [ - request.scope, - request.workspaceRoot, - request.topicId, - request.sessionPath ?? "", - ].join("\u001f"); -} - -type ProjectTreeReadActivity = Record; - -export function projectTreeTopicHasUnreadActivity( - node: ProjectNode, - readActivity: ProjectTreeReadActivity, - activeScope?: string, - activeWorkspaceRoot?: string, - activeTopicId?: string, - activeSessionPath?: string, -): boolean { - if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false; - if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false; - if (topicStatus(node) !== "") return false; - const key = projectTreeReadActivityKey(node); - const activityAt = topicActivityAt(node); - return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt); -} - -export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, variant: ProjectTreeVariant, unread: boolean): boolean { - return !isSessionNode && variant !== "creation" && !unread; -} - -// Pinning reorders the classic/workbench trees shared with creation mode, so -// the creation context menu keeps its original rename/trash-only entries. -export function projectTreeTopicMenuOffersPin(variant: ProjectTreeVariant): boolean { - return variant !== "creation"; -} - -function topicActivityLabel(ms: number, t: Translator, compact = false): string { - if (ms <= 0) return ""; - const delta = Date.now() - ms; - const locale = getLocale(); - const minute = 60_000; - const hour = 60 * minute; - const day = 24 * hour; - const month = 30 * day; - const year = 365 * day; - if (delta < minute) return t("projectTree.justNow"); - if (!compact) { - const rtfLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en"; - const rtf = new Intl.RelativeTimeFormat(rtfLocale, { numeric: "auto" }); - if (delta < hour) return rtf.format(-Math.max(1, Math.round(delta / minute)), "minute"); - if (delta < day) return rtf.format(-Math.round(delta / hour), "hour"); - if (delta < 7 * day) return rtf.format(-Math.round(delta / day), "day"); - return topicActivityDateLabel(ms); - } - if (delta < hour) { - const value = Math.max(1, Math.round(delta / minute)); - return locale === "zh" || locale === "zh-TW" ? `${value} 分钟` : `${value}m`; - } - if (delta < day) { - const value = Math.round(delta / hour); - return locale === "zh" || locale === "zh-TW" ? `${value} 小时` : `${value}h`; - } - if (delta < 7 * day) { - const value = Math.round(delta / day); - return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`; - } - if (delta < month) { - const value = Math.round(delta / day); - return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`; - } - if (delta < year) { - const value = Math.max(1, Math.round(delta / month)); - return locale === "zh" || locale === "zh-TW" ? `${value} 个月` : `${value}mo`; - } - const value = Math.max(1, Math.round(delta / year)); - return locale === "zh" || locale === "zh-TW" ? `${value} 年` : `${value}y`; -} - -function topicActivityDateLabel(ms: number): string { - if (ms <= 0) return ""; - const locale = getLocale(); - const dateLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en"; - return new Date(ms).toLocaleDateString(dateLocale); -} type ProjectDropPosition = "before" | "after"; type WorkbenchHeaderMenu = "more" | "add" | null; diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index dfd19059ce..1c1fdf64bf 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -1,7 +1,4 @@ -// bridge is the seam between React and the Go kernel. The Wails shell calls bound -// App methods and subscribes to runtime events; in a plain browser (`pnpm dev`), -// a mock streams a canned turn through the same contract so the whole UI can be -// developed and laid out without rebuilding the Go side. +// Wails and the browser mock share this React-to-Go contract. // @ts-ignore `wails generate module` creates this locally; fresh checkouts keep // typecheck green by falling back to a disabled drift check below. @@ -9,8 +6,8 @@ import type * as GeneratedApp from "../../wailsjs/go/main/App"; import type { InvocationRequest } from "./invocationDisplay"; import { addBreadcrumb } from "./breadcrumbs"; -import { asArray } from "./array"; import { maybeShare } from "./queryCoalesce"; +import { makeMockSessionCatalogBindings } from "./sessionCatalogBridge"; import { t } from "./i18n"; import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels"; import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems"; @@ -87,12 +84,7 @@ import type { PluginInstallOptions, PluginView, ProjectNode, - ProjectTreeSnapshot, - ProjectTopicPageRequest, - ProjectTopicPage, - ProjectTopicKey, - SessionCatalogStatus, - ProjectTreeChangedV2, + SessionCatalogBindings, PromptHistoryEntry, PromptHistoryResult, ProviderModelCatalogUpdate, @@ -170,7 +162,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 { +export interface AppBindings extends SessionCatalogBindings { Platform(): Promise; MinimiseMainWindow(): Promise; ToggleMaximiseMainWindow(): Promise; @@ -576,11 +568,6 @@ export interface AppBindings { CloseTerminalForTab(tabID: string, sessionID: string): Promise; RenameTerminalForTab(tabID: string, sessionID: string, title: string): Promise; ListProjectTree(): Promise; - GetProjectTreeSnapshot(): Promise; - ListProjectTopics(req: ProjectTopicPageRequest): Promise; - GetTopicSummary(key: ProjectTopicKey): Promise; - GetSessionCatalogStatus(): Promise; - RebuildSessionCatalog(): Promise; RenameProject(workspaceRoot: string, title: string): Promise; SetProjectColor(workspaceRoot: string, color: string): Promise; SetProjectPinned(workspaceRoot: string, pinned: boolean): Promise; @@ -898,21 +885,6 @@ export function onProjectTreeChanged(cb: () => void): () => void { return () => {}; } -export function onProjectTreeChangedV2(cb: (event: ProjectTreeChangedV2) => void): () => void { - if (realApp() && typeof window !== "undefined" && window.runtime) { - return window.runtime.EventsOn("project-tree:changed-v2", (payload?: unknown) => { - if (!payload || typeof payload !== "object") return; - const event = payload as Partial; - cb({ - revision: typeof event.revision === "number" ? event.revision : 0, - roots: asArray(event.roots).filter((root): root is string => typeof root === "string"), - reason: typeof event.reason === "string" ? event.reason : "changed", - }); - }); - } - return () => {}; -} - // onTopicActivation subscribes to the "topic:activation" channel carrying the // lifecycle of ticketed StartTopicActivation requests (starting/ready/failed/ // cancelled). Returns an unsubscribe. @@ -2430,6 +2402,7 @@ function makeMockApp(): AppBindings { } }; return { + ...makeMockSessionCatalogBindings(cloneProjectTree), async MinimiseMainWindow() { console.info("mock MinimiseMainWindow"); }, @@ -5173,35 +5146,6 @@ function makeMockApp(): AppBindings { async ListProjectTree() { return cloneProjectTree(); }, - async GetProjectTreeSnapshot() { - return { - revision: 1, - projects: cloneProjectTree().map((project) => ({ ...project, children: [] })), - catalog: { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 }, - indexed: 4, - total: 4, - indexingDone: true, - }; - }, - async ListProjectTopics(req: ProjectTopicPageRequest) { - const folder = req.scope === "global" - ? cloneProjectTree().find((item) => item.kind === "global_folder") - : cloneProjectTree().find((item) => item.kind === "project" && item.root === req.workspaceRoot); - const query = (req.query ?? "").trim().toLocaleLowerCase(); - const all = asArray(folder?.children).filter((item) => !query || item.label.toLocaleLowerCase().includes(query)); - const start = Math.max(0, Number.parseInt(req.cursor ?? "0", 10) || 0); - const limit = Math.min(200, Math.max(1, req.limit ?? 50)); - const items = all.slice(start, start + limit); - return { items, nextCursor: start + items.length < all.length ? String(start + items.length) : undefined, revision: 1 }; - }, - async GetTopicSummary(key: ProjectTopicKey) { - const page = await this.ListProjectTopics({ scope: key.scope, workspaceRoot: key.workspaceRoot, limit: 200 }); - return page.items.find((item) => item.topicId === key.topicId) ?? { key: "", kind: key.scope === "global" ? "global_topic" : "topic", label: "", children: [] }; - }, - async GetSessionCatalogStatus() { - return { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 }; - }, - async RebuildSessionCatalog() {}, async RenameProject(workspaceRoot: string, title: string) { const node = workspaceRoot ? mockProjectTree.find((item) => item.root === workspaceRoot) diff --git a/desktop/frontend/src/lib/projectTreeTopic.ts b/desktop/frontend/src/lib/projectTreeTopic.ts new file mode 100644 index 0000000000..3b1670ad56 --- /dev/null +++ b/desktop/frontend/src/lib/projectTreeTopic.ts @@ -0,0 +1,276 @@ +import { asArray } from "./array"; +import { getLocale, type DictKey, type Translator } from "./i18n"; +import type { ProjectNode, ProjectTopicStatus } from "./types"; + +export type ProjectTreeVariant = "classic" | "workbench" | "creation"; + +export function isRuntimeSessionNode(node: ProjectNode): boolean { + return node.kind === "session" || node.kind === "global_session"; +} + +export function isTopicNode(node: ProjectNode): boolean { + return node.kind === "topic" || node.kind === "global_topic"; +} + +export function projectTreeRevisionIsFresh(currentRevision: number, incomingRevision: number): boolean { + return incomingRevision >= currentRevision; +} + +export function mergeProjectTopicPage(current: ProjectNode[], incoming: ProjectNode[], append: boolean): ProjectNode[] { + if (!append) return [...incoming]; + const next = [...current]; + const positions = new Map(next.map((node, index) => [node.key, index])); + for (const node of incoming) { + const index = positions.get(node.key); + if (index === undefined) { + positions.set(node.key, next.length); + next.push(node); + } else { + next[index] = node; + } + } + return next; +} + +export function projectTreeEventAffectsFolder(project: ProjectNode, roots: string[]): boolean { + if (roots.length === 0) return true; + const root = project.kind === "global_folder" ? "" : project.root ?? ""; + return roots.includes(root); +} + +export type ProjectTreeTopicOpenRequest = { + scope: "global" | "project"; + workspaceRoot: string; + topicId: string; + sessionPath?: string; +}; + +export function projectTreeTopicOpenRequest(node: ProjectNode): ProjectTreeTopicOpenRequest | null { + if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return null; + const scope = node.kind === "global_topic" || node.kind === "global_session" ? "global" : "project"; + return { + scope, + workspaceRoot: scope === "global" ? "" : node.root ?? "", + topicId: node.topicId ?? "", + sessionPath: node.sessionPath, + }; +} + +export type ProjectTreeTopicClickTarget = { + rowKey: string; + canRename: boolean; +}; + +export type ProjectTreePendingTopicOpen = ProjectTreeTopicClickTarget & { + timer: ReturnType; +}; + +export function projectTreeShouldSuppressOpenForRename( + pending: ProjectTreeTopicClickTarget | null, + next: ProjectTreeTopicClickTarget, +): boolean { + return Boolean(pending && pending.rowKey === next.rowKey && pending.canRename && next.canRename); +} + +export type ProjectTreeFolderDisclosure = { + canExpand: boolean; + isOpen: boolean; + ariaExpanded?: boolean; + iconStackClassName: string; +}; + +// allowEmptyExpand lets classic folders open without children so the expanded +// state can host the "no sessions" placeholder row; other variants keep the +// original contract where empty folders are inert. +export function projectTreeFolderDisclosure(hasChildren: boolean, isExpanded: boolean, allowEmptyExpand = false): ProjectTreeFolderDisclosure { + const canExpand = hasChildren || allowEmptyExpand; + const isOpen = canExpand && isExpanded; + return { + canExpand, + isOpen, + ariaExpanded: canExpand ? isExpanded : undefined, + iconStackClassName: `project-tree__icon-stack${canExpand ? " project-tree__icon-stack--expandable" : ""}`, + }; +} + +export function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string, activeSessionPath?: string): boolean { + if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false; + if (node.sessionPath) return Boolean(activeSessionPath && activeSessionPath === node.sessionPath); + if (activeSessionPath && asArray(node.children).some(isRuntimeSessionNode)) return false; + const scope = node.kind === "global_topic" ? "global" : "project"; + return ( + activeTopicId === node.topicId && + activeScope === scope && + (scope === "global" || activeWorkspaceRoot === node.root) + ); +} + +export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string { + const parts: string[] = []; + const turns = node.turns ?? 0; + if (node.turnsState === "unknown") parts.push(t("history.indexing")); + else if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns })); + const activityAt = node.lastActivityAt || node.createdAt || 0; + if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact)); + if (parts.length === 0) parts.push(t("projectTree.previously")); + return parts.join(" · "); +} + +// Model for the classic hover preview card: the row keeps a time-only meta +// line, so the card carries the full title, turns, exact date, and project. +export type ProjectTreeTopicHoverCard = { + title: string; + statusLabel: string; + metaLine: string; + exactTime: string; + projectLabel: string; +}; + +// Activity labels older than a week are already the calendar date (always the +// meta line's last part), so callers pairing the two keep a single copy. +export function projectTreeDedupedExactTime(metaLine: string, exactTime: string): string { + return exactTime && metaLine.endsWith(exactTime) ? "" : exactTime; +} + +export function projectTreeTopicHoverCardModel(node: ProjectNode, t: Translator, projectLabel: string): ProjectTreeTopicHoverCard { + const activityAt = node.lastActivityAt || node.createdAt || 0; + const metaLine = projectTreeTopicMetaLine(node, t); + const exactTime = activityAt ? topicActivityDateLabel(activityAt) : ""; + return { + title: (node.label || node.topicId || "Untitled").replace(/^●\s*/, ""), + statusLabel: topicStatusLabel(node, t), + metaLine, + exactTime: projectTreeDedupedExactTime(metaLine, exactTime), + projectLabel, + }; +} + +export function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string { + return topicActivityAt(node) ? "" : t("projectTree.previously"); +} + +const topicStatusLabels: Record = { + thinking: "projectTree.status.thinking", + streaming: "projectTree.status.streaming", + waiting_confirmation: "projectTree.status.waitingConfirmation", + background_job: "projectTree.status.backgroundJob", + paused: "projectTree.status.paused", + error: "projectTree.status.error", +}; + +export function normalizeTopicStatus(status?: string): ProjectTopicStatus | "" { + if (!status) return ""; + if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job" || status === "paused" || status === "error") { + return status; + } + return ""; +} + +export function topicStatus(node: ProjectNode): ProjectTopicStatus | "" { + return normalizeTopicStatus(node.status) || (node.running ? "streaming" : ""); +} + +export function projectTreeTopicArchiveBlocked(node: ProjectNode): boolean { + if (asArray(node.children).some(projectTreeTopicArchiveBlocked)) return true; + const status = normalizeTopicStatus(node.status); + if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job") return true; + if (status === "paused" || status === "error") return false; + return Boolean(node.running); +} + +export function topicStatusLabel(node: ProjectNode, t: Translator): string { + const status = topicStatus(node); + return status ? t(topicStatusLabels[status]) : ""; +} + +export function topicActivityAt(node: ProjectNode): number { + return node.lastActivityAt || node.createdAt || 0; +} + +export function projectTreeReadActivityKey(node: ProjectNode): string | null { + const request = projectTreeTopicOpenRequest(node); + if (!request?.topicId) return null; + return [ + request.scope, + request.workspaceRoot, + request.topicId, + request.sessionPath ?? "", + ].join("\u001f"); +} + +export type ProjectTreeReadActivity = Record; + +export function projectTreeTopicHasUnreadActivity( + node: ProjectNode, + readActivity: ProjectTreeReadActivity, + activeScope?: string, + activeWorkspaceRoot?: string, + activeTopicId?: string, + activeSessionPath?: string, +): boolean { + if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false; + if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false; + if (topicStatus(node) !== "") return false; + const key = projectTreeReadActivityKey(node); + const activityAt = topicActivityAt(node); + return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt); +} + +export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, variant: ProjectTreeVariant, unread: boolean): boolean { + return !isSessionNode && variant !== "creation" && !unread; +} + +// Pinning reorders the classic/workbench trees shared with creation mode, so +// the creation context menu keeps its original rename/trash-only entries. +export function projectTreeTopicMenuOffersPin(variant: ProjectTreeVariant): boolean { + return variant !== "creation"; +} + +export function topicActivityLabel(ms: number, t: Translator, compact = false): string { + if (ms <= 0) return ""; + const delta = Date.now() - ms; + const locale = getLocale(); + const minute = 60_000; + const hour = 60 * minute; + const day = 24 * hour; + const month = 30 * day; + const year = 365 * day; + if (delta < minute) return t("projectTree.justNow"); + if (!compact) { + const rtfLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en"; + const rtf = new Intl.RelativeTimeFormat(rtfLocale, { numeric: "auto" }); + if (delta < hour) return rtf.format(-Math.max(1, Math.round(delta / minute)), "minute"); + if (delta < day) return rtf.format(-Math.round(delta / hour), "hour"); + if (delta < 7 * day) return rtf.format(-Math.round(delta / day), "day"); + return topicActivityDateLabel(ms); + } + if (delta < hour) { + const value = Math.max(1, Math.round(delta / minute)); + return locale === "zh" || locale === "zh-TW" ? `${value} 分钟` : `${value}m`; + } + if (delta < day) { + const value = Math.round(delta / hour); + return locale === "zh" || locale === "zh-TW" ? `${value} 小时` : `${value}h`; + } + if (delta < 7 * day) { + const value = Math.round(delta / day); + return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`; + } + if (delta < month) { + const value = Math.round(delta / day); + return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`; + } + if (delta < year) { + const value = Math.max(1, Math.round(delta / month)); + return locale === "zh" || locale === "zh-TW" ? `${value} 个月` : `${value}mo`; + } + const value = Math.max(1, Math.round(delta / year)); + return locale === "zh" || locale === "zh-TW" ? `${value} 年` : `${value}y`; +} + +export function topicActivityDateLabel(ms: number): string { + if (ms <= 0) return ""; + const locale = getLocale(); + const dateLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en"; + return new Date(ms).toLocaleDateString(dateLocale); +} diff --git a/desktop/frontend/src/lib/sessionCatalogBridge.ts b/desktop/frontend/src/lib/sessionCatalogBridge.ts new file mode 100644 index 0000000000..715cb137c1 --- /dev/null +++ b/desktop/frontend/src/lib/sessionCatalogBridge.ts @@ -0,0 +1,60 @@ +import { asArray } from "./array"; +import type { + ProjectNode, + ProjectTopicKey, + ProjectTopicPage, + ProjectTopicPageRequest, + ProjectTreeChangedV2, + SessionCatalogBindings, +} from "./types"; + +export function onProjectTreeChangedV2(cb: (event: ProjectTreeChangedV2) => void): () => void { + if (typeof window !== "undefined" && window.runtime) { + return window.runtime.EventsOn("project-tree:changed-v2", (payload?: unknown) => { + if (!payload || typeof payload !== "object") return; + const event = payload as Partial; + cb({ + revision: typeof event.revision === "number" ? event.revision : 0, + roots: asArray(event.roots).filter((root): root is string => typeof root === "string"), + reason: typeof event.reason === "string" ? event.reason : "changed", + }); + }); + } + return () => {}; +} + +export function makeMockSessionCatalogBindings(cloneProjectTree: () => ProjectNode[]): SessionCatalogBindings { + const listProjectTopics = async (req: ProjectTopicPageRequest): Promise => { + const folder = req.scope === "global" + ? cloneProjectTree().find((item) => item.kind === "global_folder") + : cloneProjectTree().find((item) => item.kind === "project" && item.root === req.workspaceRoot); + const query = (req.query ?? "").trim().toLocaleLowerCase(); + const all = asArray(folder?.children).filter((item) => !query || item.label.toLocaleLowerCase().includes(query)); + const start = Math.max(0, Number.parseInt(req.cursor ?? "0", 10) || 0); + const limit = Math.min(200, Math.max(1, req.limit ?? 50)); + const items = all.slice(start, start + limit); + return { items, nextCursor: start + items.length < all.length ? String(start + items.length) : undefined, revision: 1 }; + }; + return { + async GetProjectTreeSnapshot() { + return { + revision: 1, + projects: cloneProjectTree().map((project) => ({ ...project, children: [] })), + catalog: { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 }, + indexed: 4, + total: 4, + indexingDone: true, + }; + }, + ListProjectTopics: listProjectTopics, + async GetTopicSummary(key: ProjectTopicKey) { + const page = await listProjectTopics({ scope: key.scope, workspaceRoot: key.workspaceRoot, limit: 200 }); + return page.items.find((item) => item.topicId === key.topicId) + ?? { key: "", kind: key.scope === "global" ? "global_topic" : "topic", label: "", children: [] }; + }, + async GetSessionCatalogStatus() { + return { state: "ready", mode: "memory", revision: 1, indexed: 4, total: 4, repairPending: 0 }; + }, + async RebuildSessionCatalog() {}, + }; +} diff --git a/desktop/frontend/src/lib/sessionCatalogPresentation.ts b/desktop/frontend/src/lib/sessionCatalogPresentation.ts new file mode 100644 index 0000000000..7944363644 --- /dev/null +++ b/desktop/frontend/src/lib/sessionCatalogPresentation.ts @@ -0,0 +1,12 @@ +import type { Translator } from "./i18n"; + +type SessionTurns = { + turns?: number; + turnsState?: string; +}; + +export function sessionTurnsLabel(session: SessionTurns, t: Translator): string { + if (session.turnsState === "unknown") return t("history.indexing"); + if (typeof session.turns === "number") return t("composer.sessionTurns", { n: session.turns }); + return ""; +} diff --git a/desktop/frontend/src/lib/sessionCatalogTypes.ts b/desktop/frontend/src/lib/sessionCatalogTypes.ts new file mode 100644 index 0000000000..57146495a0 --- /dev/null +++ b/desktop/frontend/src/lib/sessionCatalogTypes.ts @@ -0,0 +1,67 @@ +import type { ProjectNode } from "./types"; + +export interface SessionCatalogStatus { + state: "opening" | "ready" | "degraded" | "rebuilding" | "closed" | string; + mode?: "disk" | "memory" | string; + revision: number; + indexed: number; + total: number; + repairPending: number; + lastError?: string; + quarantinedPath?: string; +} + +export interface ProjectTreeSnapshot { + revision: number; + projects: ProjectNode[]; + catalog: SessionCatalogStatus; + indexed: number; + total: number; + indexingDone: boolean; +} + +export interface ProjectTopicPageRequest { + scope: "global" | "project" | string; + workspaceRoot?: string; + cursor?: string; + limit?: number; + query?: string; + timeFilter?: string; +} + +export interface ProjectTopicPage { + items: ProjectNode[]; + nextCursor?: string; + revision: number; +} + +export interface ProjectTopicKey { + scope: "global" | "project" | string; + workspaceRoot?: string; + topicId: string; +} + +export interface ProjectTreeChangedV2 { + revision: number; + roots: string[]; + reason: string; +} + +export interface SessionCatalogBindings { + GetProjectTreeSnapshot(): Promise; + ListProjectTopics(req: ProjectTopicPageRequest): Promise; + GetTopicSummary(key: ProjectTopicKey): Promise; + GetSessionCatalogStatus(): Promise; + RebuildSessionCatalog(): Promise; +} + +// SessionReference is a session selected via @ past:chats for context injection. +export interface SessionReference { + path: string; + title: string; + preview?: string; + turns?: number; + turnsState?: "unknown" | "valid" | "corrupt" | string; + createdAt?: number; + lastActivityAt?: number; +} diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index 4b54313ae3..eb00780e85 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -4,6 +4,7 @@ import type { Todo } from "./tools"; import type { ContextMaintenanceInfo, WireContextMaintenance } from "./contextMaintenanceTypes"; export type { ContextMaintenanceInfo, ContextMaintenanceReceipt, WireContextMaintenance } from "./contextMaintenanceTypes"; +export type { ProjectTopicKey, ProjectTopicPage, ProjectTopicPageRequest, ProjectTreeChangedV2, ProjectTreeSnapshot, SessionCatalogBindings, SessionCatalogStatus, SessionReference } from "./sessionCatalogTypes"; export type EventKind = | "turn_started" @@ -457,53 +458,6 @@ export interface ProjectNode { children?: ProjectNode[]; } -export interface SessionCatalogStatus { - state: "opening" | "ready" | "degraded" | "rebuilding" | "closed" | string; - mode?: "disk" | "memory" | string; - revision: number; - indexed: number; - total: number; - repairPending: number; - lastError?: string; - quarantinedPath?: string; -} - -export interface ProjectTreeSnapshot { - revision: number; - projects: ProjectNode[]; - catalog: SessionCatalogStatus; - indexed: number; - total: number; - indexingDone: boolean; -} - -export interface ProjectTopicPageRequest { - scope: "global" | "project" | string; - workspaceRoot?: string; - cursor?: string; - limit?: number; - query?: string; - timeFilter?: string; -} - -export interface ProjectTopicPage { - items: ProjectNode[]; - nextCursor?: string; - revision: number; -} - -export interface ProjectTopicKey { - scope: "global" | "project" | string; - workspaceRoot?: string; - topicId: string; -} - -export interface ProjectTreeChangedV2 { - revision: number; - roots: string[]; - reason: string; -} - export interface DeliveryWorktreeAvailability { available: boolean; reason?: string; @@ -843,17 +797,6 @@ export interface SessionMeta { recoveryCopy?: boolean; // actual branch content is unchanged and covered by its parent } -// SessionReference is a session selected via @ past:chats for context injection. -export interface SessionReference { - path: string; - title: string; - preview?: string; - turns?: number; - turnsState?: "unknown" | "valid" | "corrupt" | string; - createdAt?: number; - lastActivityAt?: number; -} - export interface WorkspaceView { path: string; name: string; diff --git a/desktop/session_catalog.go b/desktop/session_catalog.go index 8838e1dd9a..680ceee442 100644 --- a/desktop/session_catalog.go +++ b/desktop/session_catalog.go @@ -5,12 +5,10 @@ import ( "errors" "log/slog" "path/filepath" - "sort" "strings" "time" "reasonix/internal/config" - "reasonix/internal/control" "reasonix/internal/sessioncatalog" ) @@ -189,10 +187,7 @@ func (a *App) stopSessionCatalog(timeout time.Duration) { catalog := a.sessionCatalog.Swap(nil) deadline := time.Now().Add(timeout) if catalog != nil { - remaining := time.Until(deadline) - if remaining < 0 { - remaining = 0 - } + remaining := max(time.Until(deadline), 0) ctx, closeCancel := context.WithTimeout(context.Background(), remaining) _ = catalog.Close(ctx) closeCancel() @@ -433,470 +428,3 @@ func (a *App) GetProjectTreeSnapshot() ProjectTreeSnapshot { IndexingDone: status.State == string(sessioncatalog.StateReady) && status.RepairPending == 0, } } - -type catalogRuntimeSnapshot struct { - scope string - workspaceRoot string - topicID string - sessionPath string - activity string - topicTitle string - ctrl control.SessionAPI - open bool -} - -type catalogRuntimeOverlay struct { - open bool - running bool - status string -} - -func catalogRuntimeStatus(activity string, runtimeStatus control.RuntimeStatus) string { - status := normalizeTopicStatus(activity) - if runtimeStatus.PendingPrompt { - return topicStatusWaitingConfirmation - } - if runtimeStatus.Running { - if status == "" || status == topicStatusError || status == topicStatusPaused { - return topicStatusThinking - } - return status - } - if runtimeStatus.BackgroundJobs > 0 { - return topicStatusBackgroundJob - } - if status == topicStatusError || status == topicStatusPaused { - return status - } - return status -} - -func (a *App) catalogRuntimeOverlays() (map[string]catalogRuntimeOverlay, map[string]catalogRuntimeOverlay) { - a.mu.RLock() - snapshots := make([]catalogRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions)) - collect := func(tab *WorkspaceTab, open bool) { - if tab == nil || strings.TrimSpace(tab.TopicID) == "" { - return - } - snapshots = append(snapshots, catalogRuntimeSnapshot{ - scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID, - sessionPath: tab.SessionPath, activity: tab.ActivityStatus, topicTitle: tab.TopicTitle, - ctrl: tab.Ctrl, open: open, - }) - } - for _, tab := range a.tabs { - collect(tab, true) - } - for _, tab := range a.detachedSessions { - collect(tab, false) - } - a.mu.RUnlock() - topics := map[string]catalogRuntimeOverlay{} - sessions := map[string]catalogRuntimeOverlay{} - for _, snap := range snapshots { - runtimeStatus := control.RuntimeStatus{} - path := strings.TrimSpace(snap.sessionPath) - if snap.ctrl != nil { - runtimeStatus = snap.ctrl.RuntimeStatus() - if path == "" { - path = snap.ctrl.SessionPath() - } - } - status := catalogRuntimeStatus(snap.activity, runtimeStatus) - running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0 - overlay := catalogRuntimeOverlay{open: snap.open, running: running, status: status} - key := topicSummaryKey(snap.scope, snap.workspaceRoot, snap.topicID) - current := topics[key] - current.open = current.open || overlay.open - current.running = current.running || overlay.running - if current.status == "" { - current.status = overlay.status - } - topics[key] = current - if path != "" { - sessions[sessionRuntimeKey(path)] = overlay - } - } - return topics, sessions -} - -func (a *App) metadataProjectTopics(scope, workspaceRoot string) []ProjectNode { - f := loadProjectsFile() - deleted := map[string]bool{} - for _, topicID := range f.DeletedTopics { - deleted[topicID] = true - } - ids := f.GlobalTopics - pinnedIDs := f.GlobalPinnedTopics - titleRoot := "" - projectColor := normalizeProjectColor(f.GlobalColor) - if scope == "project" { - ids = nil - pinnedIDs = nil - titleRoot = workspaceRoot - for _, project := range f.Projects { - if sameProjectRoot(project.Root, workspaceRoot) { - ids = project.Topics - pinnedIDs = project.PinnedTopics - projectColor = project.Color - break - } - } - } - titles := loadTopicTitles(titleRoot) - sources := loadTopicTitleSources(titleRoot) - created := loadTopicCreatedAts(titleRoot) - topicOverlays, _ := a.catalogRuntimeOverlays() - runtimeNodes := a.runtimeOnlyProjectTopics(scope, workspaceRoot) - runtimeByTopic := map[string]ProjectNode{} - for _, node := range runtimeNodes { - runtimeByTopic[node.TopicID] = node - } - out := []ProjectNode{} - seen := map[string]bool{} - for _, topicID := range pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs) { - if deleted[topicID] { - continue - } - seen[topicID] = true - title := strings.TrimSpace(titles[topicID]) - if title == "" { - title = defaultTopicTitle - } - kind := "topic" - if scope != "project" { - kind = "global_topic" - } - overlay := topicOverlays[topicSummaryKey(scope, workspaceRoot, topicID)] - node := ProjectNode{ - Key: kind + "_" + topicID, Kind: kind, - Label: a.localizedTopicTitle(title, sources[topicID]), Root: workspaceRoot, - TopicID: topicID, ProjectColor: projectColor, - CreatedAt: topicCreatedAtForTree(created, topicID), Pinned: containsDesktopString(pinnedIDs, topicID), - Open: overlay.open, Running: overlay.running, Status: overlay.status, - TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK), - Children: []ProjectNode{}, - } - if runtimeNode, ok := runtimeByTopic[topicID]; ok { - node.Open = runtimeNode.Open - node.Running = runtimeNode.Running - node.Status = runtimeNode.Status - node.Children = runtimeNode.Children - } - out = append(out, node) - } - for _, runtimeNode := range runtimeNodes { - if seen[runtimeNode.TopicID] || deleted[runtimeNode.TopicID] { - continue - } - out = append(out, runtimeNode) - } - return out -} - -func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNode { - a.mu.RLock() - snapshots := []catalogRuntimeSnapshot{} - collect := func(tab *WorkspaceTab, open bool) { - if tab == nil || strings.TrimSpace(tab.TopicID) == "" { - return - } - if scope == "project" { - if tab.Scope != "project" || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) { - return - } - } else if tab.Scope == "project" { - return - } - snapshots = append(snapshots, catalogRuntimeSnapshot{ - scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID, - sessionPath: tab.SessionPath, activity: tab.ActivityStatus, - topicTitle: tab.TopicTitle, ctrl: tab.Ctrl, open: open, - }) - } - for _, tab := range a.tabs { - collect(tab, true) - } - for _, tab := range a.detachedSessions { - collect(tab, false) - } - a.mu.RUnlock() - byTopic := map[string][]catalogRuntimeSnapshot{} - for _, snapshot := range snapshots { - if snapshot.sessionPath == "" && snapshot.ctrl != nil { - snapshot.sessionPath = snapshot.ctrl.SessionPath() - } - byTopic[snapshot.topicID] = append(byTopic[snapshot.topicID], snapshot) - } - topicIDs := make([]string, 0, len(byTopic)) - for topicID := range byTopic { - topicIDs = append(topicIDs, topicID) - } - sort.Strings(topicIDs) - out := []ProjectNode{} - for _, topicID := range topicIDs { - sessions := byTopic[topicID] - kind := "topic" - sessionKind := "session" - if scope != "project" { - kind = "global_topic" - sessionKind = "global_session" - } - label := defaultTopicTitle - if strings.TrimSpace(sessions[0].topicTitle) != "" { - label = sessions[0].topicTitle - } - node := ProjectNode{ - Key: kind + "_" + topicID, Kind: kind, Label: label, - Root: workspaceRoot, TopicID: topicID, TurnsState: string(sessioncatalog.TurnsUnknown), - Health: string(sessioncatalog.HealthOK), Children: []ProjectNode{}, - } - for _, session := range sessions { - runtimeStatus := control.RuntimeStatus{} - if session.ctrl != nil { - runtimeStatus = session.ctrl.RuntimeStatus() - } - status := catalogRuntimeStatus(session.activity, runtimeStatus) - running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0 - if len(sessions) == 1 { - node.Open = session.open - node.Running = running - node.Status = status - continue - } - path := strings.TrimSpace(session.sessionPath) - sessionLabel := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - if sessionLabel == "" || sessionLabel == "." { - sessionLabel = label - } - node.Children = append(node.Children, ProjectNode{ - Key: projectSessionNodeKey(scope, path), Kind: sessionKind, Label: sessionLabel, - Root: workspaceRoot, TopicID: topicID, SessionPath: path, - Open: session.open, Running: running, Status: status, - TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK), - Children: []ProjectNode{}, - }) - } - out = append(out, node) - } - return out -} - -func (a *App) metadataTopicPage(req ProjectTopicPageRequest) ProjectTopicPage { - items := a.metadataProjectTopics(req.Scope, req.WorkspaceRoot) - query := strings.ToLower(strings.TrimSpace(req.Query)) - if query != "" { - filtered := items[:0] - for _, item := range items { - if strings.Contains(strings.ToLower(item.Label), query) { - filtered = append(filtered, item) - } - } - items = filtered - } - start := 0 - if strings.HasPrefix(req.Cursor, "meta:") { - lastID := strings.TrimPrefix(req.Cursor, "meta:") - for index, item := range items { - if item.TopicID == lastID { - start = index + 1 - break - } - } - } - limit := req.Limit - if limit <= 0 { - limit = sessioncatalog.DefaultLimit - } - if limit > sessioncatalog.MaxLimit { - limit = sessioncatalog.MaxLimit - } - end := min(start+limit, len(items)) - page := ProjectTopicPage{Items: append([]ProjectNode(nil), items[start:end]...)} - if end < len(items) && end > start { - page.NextCursor = "meta:" + items[end-1].TopicID - } - return page -} - -func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay) ProjectNode { - kind := "topic" - if topic.Scope == "global" { - kind = "global_topic" - } - overlay := topicOverlays[topicSummaryKey(topic.Scope, topic.WorkspaceRoot, topic.TopicID)] - node := ProjectNode{ - Key: kind + "_" + topic.TopicID, Kind: kind, Label: a.localizedTopicTitle(topic.Title, ""), - Root: topic.WorkspaceRoot, TopicID: topic.TopicID, Turns: topic.Turns, - TurnsState: string(topic.TurnsState), Health: string(topic.Health), - CreatedAt: topic.CreatedAt, LastActivityAt: topic.LastActivityAt, - Pinned: topic.Pinned, Open: overlay.open, Running: overlay.running, Status: overlay.status, - Children: []ProjectNode{}, - } - if len(topic.Sessions) <= 1 { - return node - } - for _, session := range topic.Sessions { - sessionKind := "session" - if topic.Scope == "global" { - sessionKind = "global_session" - } - sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)] - label := strings.TrimSpace(session.CustomTitle) - if label == "" { - label = strings.TrimSpace(session.Preview) - } - if label == "" { - label = filepath.Base(session.Path) - } - node.Children = append(node.Children, ProjectNode{ - Key: projectSessionNodeKey(topic.Scope, session.Path), Kind: sessionKind, - Label: label, Root: topic.WorkspaceRoot, TopicID: topic.TopicID, - SessionPath: session.Path, Turns: session.Turns, - TurnsState: string(session.TurnsState), Health: string(session.Health), - CreatedAt: session.CreatedAt, LastActivityAt: session.LastActivityAt, - Open: sessionOverlay.open, Running: sessionOverlay.running, Status: sessionOverlay.status, - Recovered: session.Recovered, RecoveryReason: session.RecoveryReason, - RecoveryDigest: session.RecoveryDigest, RecoveryParentID: session.ParentID, - Children: []ProjectNode{}, - }) - } - return node -} - -func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage, error) { - out := ProjectTopicPage{Items: []ProjectNode{}} - catalog := a.sessionCatalog.Load() - if catalog == nil { - return a.metadataTopicPage(req), nil - } - page, err := catalog.ListTopics(a.bootContext(), sessioncatalog.TopicPageRequest{ - Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: req.Cursor, - Limit: req.Limit, Query: req.Query, TimeFilter: req.TimeFilter, - }) - if err != nil { - return out, err - } - topicOverlays, sessionOverlays := a.catalogRuntimeOverlays() - for _, topic := range page.Items { - out.Items = append(out.Items, a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays)) - } - out.NextCursor = page.NextCursor - out.Revision = page.Revision - return out, nil -} - -func (a *App) GetTopicSummary(key ProjectTopicKey) (ProjectNode, error) { - if catalog := a.sessionCatalog.Load(); catalog != nil { - topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{ - Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID, - }) - if err != nil { - return ProjectNode{Children: []ProjectNode{}}, err - } - if ok { - topicOverlays, sessionOverlays := a.catalogRuntimeOverlays() - return a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays), nil - } - } - page, err := a.ListProjectTopics(ProjectTopicPageRequest{ - Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, Limit: sessioncatalog.MaxLimit, - }) - if err != nil { - return ProjectNode{Children: []ProjectNode{}}, err - } - for _, node := range page.Items { - if node.TopicID == key.TopicID { - return node, nil - } - } - return ProjectNode{Children: []ProjectNode{}}, nil -} - -func (a *App) GetSessionCatalogStatus() SessionCatalogStatus { - return a.currentSessionCatalogStatus() -} - -func (a *App) RebuildSessionCatalog() error { - if a == nil || a.shuttingDown.Load() { - return errors.New("application is shutting down") - } - if !a.catalogRebuilding.CompareAndSwap(false, true) { - return nil - } - go func() { - a.stopSessionCatalog(250 * time.Millisecond) - a.catalogRebuilding.Store(false) - a.startSessionCatalog(true) - }() - return nil -} - -// ListProjectTree is the one-release compatibility wrapper. It composes only -// catalog pages and project shells; it never migrates, scans, or decodes a -// session synchronously. -func (a *App) ListProjectTree() []ProjectNode { - snapshot := a.GetProjectTreeSnapshot() - hasGlobal := false - for _, project := range snapshot.Projects { - if project.Kind == "global_folder" { - hasGlobal = true - break - } - } - if !hasGlobal && len(a.metadataProjectTopics("global", "")) > 0 { - f := loadProjectsFile() - label := strings.TrimSpace(f.GlobalTitle) - if label == "" { - label = "Global" - } - snapshot.Projects = append(snapshot.Projects, ProjectNode{ - Key: "global_folder", Kind: "global_folder", Label: label, - Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor), Children: []ProjectNode{}, - }) - snapshot.Projects = applyPinnedProjectOrder(applyProjectTreeOrder(snapshot.Projects, f.SidebarOrder), f.PinnedProjects) - } - for index := range snapshot.Projects { - project := &snapshot.Projects[index] - scope := "project" - root := project.Root - if project.Kind == "global_folder" { - scope = "global" - root = "" - } - cursor := "" - for { - page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Cursor: cursor, Limit: sessioncatalog.MaxLimit}) - if err != nil { - break - } - project.Children = append(project.Children, page.Items...) - if page.NextCursor == "" { - break - } - cursor = page.NextCursor - } - if len(project.Children) == 0 { - project.Children = a.metadataProjectTopics(scope, root) - } - } - return snapshot.Projects -} - -func (a *App) catalogSessionPathForTopic(scope, workspaceRoot, topicID string) string { - if strings.TrimSpace(topicID) == "" { - return "" - } - catalog := a.sessionCatalog.Load() - if catalog == nil { - return "" - } - topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID}) - if err != nil || !ok || len(topic.Sessions) == 0 { - return "" - } - sort.SliceStable(topic.Sessions, func(i, j int) bool { - return topic.Sessions[i].LastActivityAt > topic.Sessions[j].LastActivityAt - }) - return topic.Sessions[0].Path -} diff --git a/desktop/session_catalog_runtime.go b/desktop/session_catalog_runtime.go new file mode 100644 index 0000000000..4178d61c5f --- /dev/null +++ b/desktop/session_catalog_runtime.go @@ -0,0 +1,478 @@ +package main + +import ( + "errors" + "path/filepath" + "sort" + "strings" + "time" + + "reasonix/internal/control" + "reasonix/internal/sessioncatalog" +) + +type catalogRuntimeSnapshot struct { + scope string + workspaceRoot string + topicID string + sessionPath string + activity string + topicTitle string + ctrl control.SessionAPI + open bool +} + +type catalogRuntimeOverlay struct { + open bool + running bool + status string +} + +func catalogRuntimeStatus(activity string, runtimeStatus control.RuntimeStatus) string { + status := normalizeTopicStatus(activity) + if runtimeStatus.PendingPrompt { + return topicStatusWaitingConfirmation + } + if runtimeStatus.Running { + if status == "" || status == topicStatusError || status == topicStatusPaused { + return topicStatusThinking + } + return status + } + if runtimeStatus.BackgroundJobs > 0 { + return topicStatusBackgroundJob + } + if status == topicStatusError || status == topicStatusPaused { + return status + } + return status +} + +func (a *App) catalogRuntimeOverlays() (map[string]catalogRuntimeOverlay, map[string]catalogRuntimeOverlay) { + a.mu.RLock() + snapshots := make([]catalogRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions)) + collect := func(tab *WorkspaceTab, open bool) { + if tab == nil || strings.TrimSpace(tab.TopicID) == "" { + return + } + snapshots = append(snapshots, catalogRuntimeSnapshot{ + scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID, + sessionPath: tab.SessionPath, activity: tab.ActivityStatus, topicTitle: tab.TopicTitle, + ctrl: tab.Ctrl, open: open, + }) + } + for _, tab := range a.tabs { + collect(tab, true) + } + for _, tab := range a.detachedSessions { + collect(tab, false) + } + a.mu.RUnlock() + topics := map[string]catalogRuntimeOverlay{} + sessions := map[string]catalogRuntimeOverlay{} + for _, snap := range snapshots { + runtimeStatus := control.RuntimeStatus{} + path := strings.TrimSpace(snap.sessionPath) + if snap.ctrl != nil { + runtimeStatus = snap.ctrl.RuntimeStatus() + if path == "" { + path = snap.ctrl.SessionPath() + } + } + status := catalogRuntimeStatus(snap.activity, runtimeStatus) + running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0 + overlay := catalogRuntimeOverlay{open: snap.open, running: running, status: status} + key := topicSummaryKey(snap.scope, snap.workspaceRoot, snap.topicID) + current := topics[key] + current.open = current.open || overlay.open + current.running = current.running || overlay.running + if current.status == "" { + current.status = overlay.status + } + topics[key] = current + if path != "" { + sessions[sessionRuntimeKey(path)] = overlay + } + } + return topics, sessions +} + +func (a *App) metadataProjectTopics(scope, workspaceRoot string) []ProjectNode { + f := loadProjectsFile() + deleted := map[string]bool{} + for _, topicID := range f.DeletedTopics { + deleted[topicID] = true + } + ids := f.GlobalTopics + pinnedIDs := f.GlobalPinnedTopics + titleRoot := "" + projectColor := normalizeProjectColor(f.GlobalColor) + if scope == "project" { + ids = nil + pinnedIDs = nil + titleRoot = workspaceRoot + for _, project := range f.Projects { + if sameProjectRoot(project.Root, workspaceRoot) { + ids = project.Topics + pinnedIDs = project.PinnedTopics + projectColor = project.Color + break + } + } + } + titles := loadTopicTitles(titleRoot) + sources := loadTopicTitleSources(titleRoot) + created := loadTopicCreatedAts(titleRoot) + topicOverlays, _ := a.catalogRuntimeOverlays() + runtimeNodes := a.runtimeOnlyProjectTopics(scope, workspaceRoot) + runtimeByTopic := map[string]ProjectNode{} + for _, node := range runtimeNodes { + runtimeByTopic[node.TopicID] = node + } + out := []ProjectNode{} + seen := map[string]bool{} + for _, topicID := range pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs) { + if deleted[topicID] { + continue + } + seen[topicID] = true + title := strings.TrimSpace(titles[topicID]) + if title == "" { + title = defaultTopicTitle + } + kind := "topic" + if scope != "project" { + kind = "global_topic" + } + overlay := topicOverlays[topicSummaryKey(scope, workspaceRoot, topicID)] + node := ProjectNode{ + Key: kind + "_" + topicID, Kind: kind, + Label: a.localizedTopicTitle(title, sources[topicID]), Root: workspaceRoot, + TopicID: topicID, ProjectColor: projectColor, + CreatedAt: topicCreatedAtForTree(created, topicID), Pinned: containsDesktopString(pinnedIDs, topicID), + Open: overlay.open, Running: overlay.running, Status: overlay.status, + TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK), + Children: []ProjectNode{}, + } + if runtimeNode, ok := runtimeByTopic[topicID]; ok { + node.Open = runtimeNode.Open + node.Running = runtimeNode.Running + node.Status = runtimeNode.Status + node.Children = runtimeNode.Children + } + out = append(out, node) + } + for _, runtimeNode := range runtimeNodes { + if seen[runtimeNode.TopicID] || deleted[runtimeNode.TopicID] { + continue + } + out = append(out, runtimeNode) + } + return out +} + +func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNode { + a.mu.RLock() + snapshots := []catalogRuntimeSnapshot{} + collect := func(tab *WorkspaceTab, open bool) { + if tab == nil || strings.TrimSpace(tab.TopicID) == "" { + return + } + if scope == "project" { + if tab.Scope != "project" || !sameProjectRoot(tab.WorkspaceRoot, workspaceRoot) { + return + } + } else if tab.Scope == "project" { + return + } + snapshots = append(snapshots, catalogRuntimeSnapshot{ + scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID, + sessionPath: tab.SessionPath, activity: tab.ActivityStatus, + topicTitle: tab.TopicTitle, ctrl: tab.Ctrl, open: open, + }) + } + for _, tab := range a.tabs { + collect(tab, true) + } + for _, tab := range a.detachedSessions { + collect(tab, false) + } + a.mu.RUnlock() + byTopic := map[string][]catalogRuntimeSnapshot{} + for _, snapshot := range snapshots { + if snapshot.sessionPath == "" && snapshot.ctrl != nil { + snapshot.sessionPath = snapshot.ctrl.SessionPath() + } + byTopic[snapshot.topicID] = append(byTopic[snapshot.topicID], snapshot) + } + topicIDs := make([]string, 0, len(byTopic)) + for topicID := range byTopic { + topicIDs = append(topicIDs, topicID) + } + sort.Strings(topicIDs) + out := []ProjectNode{} + for _, topicID := range topicIDs { + sessions := byTopic[topicID] + kind := "topic" + sessionKind := "session" + if scope != "project" { + kind = "global_topic" + sessionKind = "global_session" + } + label := defaultTopicTitle + if strings.TrimSpace(sessions[0].topicTitle) != "" { + label = sessions[0].topicTitle + } + node := ProjectNode{ + Key: kind + "_" + topicID, Kind: kind, Label: label, + Root: workspaceRoot, TopicID: topicID, TurnsState: string(sessioncatalog.TurnsUnknown), + Health: string(sessioncatalog.HealthOK), Children: []ProjectNode{}, + } + for _, session := range sessions { + runtimeStatus := control.RuntimeStatus{} + if session.ctrl != nil { + runtimeStatus = session.ctrl.RuntimeStatus() + } + status := catalogRuntimeStatus(session.activity, runtimeStatus) + running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0 + if len(sessions) == 1 { + node.Open = session.open + node.Running = running + node.Status = status + continue + } + path := strings.TrimSpace(session.sessionPath) + sessionLabel := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if sessionLabel == "" || sessionLabel == "." { + sessionLabel = label + } + node.Children = append(node.Children, ProjectNode{ + Key: projectSessionNodeKey(scope, path), Kind: sessionKind, Label: sessionLabel, + Root: workspaceRoot, TopicID: topicID, SessionPath: path, + Open: session.open, Running: running, Status: status, + TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK), + Children: []ProjectNode{}, + }) + } + out = append(out, node) + } + return out +} + +func (a *App) metadataTopicPage(req ProjectTopicPageRequest) ProjectTopicPage { + items := a.metadataProjectTopics(req.Scope, req.WorkspaceRoot) + query := strings.ToLower(strings.TrimSpace(req.Query)) + if query != "" { + filtered := items[:0] + for _, item := range items { + if strings.Contains(strings.ToLower(item.Label), query) { + filtered = append(filtered, item) + } + } + items = filtered + } + start := 0 + if lastID, ok := strings.CutPrefix(req.Cursor, "meta:"); ok { + for index, item := range items { + if item.TopicID == lastID { + start = index + 1 + break + } + } + } + limit := req.Limit + if limit <= 0 { + limit = sessioncatalog.DefaultLimit + } + if limit > sessioncatalog.MaxLimit { + limit = sessioncatalog.MaxLimit + } + end := min(start+limit, len(items)) + page := ProjectTopicPage{Items: append([]ProjectNode(nil), items[start:end]...)} + if end < len(items) && end > start { + page.NextCursor = "meta:" + items[end-1].TopicID + } + return page +} + +func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay) ProjectNode { + kind := "topic" + if topic.Scope == "global" { + kind = "global_topic" + } + overlay := topicOverlays[topicSummaryKey(topic.Scope, topic.WorkspaceRoot, topic.TopicID)] + node := ProjectNode{ + Key: kind + "_" + topic.TopicID, Kind: kind, Label: a.localizedTopicTitle(topic.Title, ""), + Root: topic.WorkspaceRoot, TopicID: topic.TopicID, Turns: topic.Turns, + TurnsState: string(topic.TurnsState), Health: string(topic.Health), + CreatedAt: topic.CreatedAt, LastActivityAt: topic.LastActivityAt, + Pinned: topic.Pinned, Open: overlay.open, Running: overlay.running, Status: overlay.status, + Children: []ProjectNode{}, + } + if len(topic.Sessions) <= 1 { + return node + } + for _, session := range topic.Sessions { + sessionKind := "session" + if topic.Scope == "global" { + sessionKind = "global_session" + } + sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)] + label := strings.TrimSpace(session.CustomTitle) + if label == "" { + label = strings.TrimSpace(session.Preview) + } + if label == "" { + label = filepath.Base(session.Path) + } + node.Children = append(node.Children, ProjectNode{ + Key: projectSessionNodeKey(topic.Scope, session.Path), Kind: sessionKind, + Label: label, Root: topic.WorkspaceRoot, TopicID: topic.TopicID, + SessionPath: session.Path, Turns: session.Turns, + TurnsState: string(session.TurnsState), Health: string(session.Health), + CreatedAt: session.CreatedAt, LastActivityAt: session.LastActivityAt, + Open: sessionOverlay.open, Running: sessionOverlay.running, Status: sessionOverlay.status, + Recovered: session.Recovered, RecoveryReason: session.RecoveryReason, + RecoveryDigest: session.RecoveryDigest, RecoveryParentID: session.ParentID, + Children: []ProjectNode{}, + }) + } + return node +} + +func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage, error) { + out := ProjectTopicPage{Items: []ProjectNode{}} + catalog := a.sessionCatalog.Load() + if catalog == nil { + return a.metadataTopicPage(req), nil + } + page, err := catalog.ListTopics(a.bootContext(), sessioncatalog.TopicPageRequest{ + Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: req.Cursor, + Limit: req.Limit, Query: req.Query, TimeFilter: req.TimeFilter, + }) + if err != nil { + return out, err + } + topicOverlays, sessionOverlays := a.catalogRuntimeOverlays() + for _, topic := range page.Items { + out.Items = append(out.Items, a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays)) + } + out.NextCursor = page.NextCursor + out.Revision = page.Revision + return out, nil +} + +func (a *App) GetTopicSummary(key ProjectTopicKey) (ProjectNode, error) { + if catalog := a.sessionCatalog.Load(); catalog != nil { + topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{ + Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID, + }) + if err != nil { + return ProjectNode{Children: []ProjectNode{}}, err + } + if ok { + topicOverlays, sessionOverlays := a.catalogRuntimeOverlays() + return a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays), nil + } + } + page, err := a.ListProjectTopics(ProjectTopicPageRequest{ + Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, Limit: sessioncatalog.MaxLimit, + }) + if err != nil { + return ProjectNode{Children: []ProjectNode{}}, err + } + for _, node := range page.Items { + if node.TopicID == key.TopicID { + return node, nil + } + } + return ProjectNode{Children: []ProjectNode{}}, nil +} + +func (a *App) GetSessionCatalogStatus() SessionCatalogStatus { + return a.currentSessionCatalogStatus() +} + +func (a *App) RebuildSessionCatalog() error { + if a == nil || a.shuttingDown.Load() { + return errors.New("application is shutting down") + } + if !a.catalogRebuilding.CompareAndSwap(false, true) { + return nil + } + go func() { + a.stopSessionCatalog(250 * time.Millisecond) + a.catalogRebuilding.Store(false) + a.startSessionCatalog(true) + }() + return nil +} + +// ListProjectTree is the one-release compatibility wrapper. It composes only +// catalog pages and project shells; it never migrates, scans, or decodes a +// session synchronously. +func (a *App) ListProjectTree() []ProjectNode { + snapshot := a.GetProjectTreeSnapshot() + hasGlobal := false + for _, project := range snapshot.Projects { + if project.Kind == "global_folder" { + hasGlobal = true + break + } + } + if !hasGlobal && len(a.metadataProjectTopics("global", "")) > 0 { + f := loadProjectsFile() + label := strings.TrimSpace(f.GlobalTitle) + if label == "" { + label = "Global" + } + snapshot.Projects = append(snapshot.Projects, ProjectNode{ + Key: "global_folder", Kind: "global_folder", Label: label, + Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor), Children: []ProjectNode{}, + }) + snapshot.Projects = applyPinnedProjectOrder(applyProjectTreeOrder(snapshot.Projects, f.SidebarOrder), f.PinnedProjects) + } + for index := range snapshot.Projects { + project := &snapshot.Projects[index] + scope := "project" + root := project.Root + if project.Kind == "global_folder" { + scope = "global" + root = "" + } + cursor := "" + for { + page, err := a.ListProjectTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Cursor: cursor, Limit: sessioncatalog.MaxLimit}) + if err != nil { + break + } + project.Children = append(project.Children, page.Items...) + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + } + if len(project.Children) == 0 { + project.Children = a.metadataProjectTopics(scope, root) + } + } + return snapshot.Projects +} + +func (a *App) catalogSessionPathForTopic(scope, workspaceRoot, topicID string) string { + if strings.TrimSpace(topicID) == "" { + return "" + } + catalog := a.sessionCatalog.Load() + if catalog == nil { + return "" + } + topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID}) + if err != nil || !ok || len(topic.Sessions) == 0 { + return "" + } + sort.SliceStable(topic.Sessions, func(i, j int) bool { + return topic.Sessions[i].LastActivityAt > topic.Sessions[j].LastActivityAt + }) + return topic.Sessions[0].Path +} diff --git a/desktop/session_meta.go b/desktop/session_meta.go new file mode 100644 index 0000000000..cb05a52094 --- /dev/null +++ b/desktop/session_meta.go @@ -0,0 +1,29 @@ +package main + +import "reasonix/internal/agent" + +func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta { + turnsState := "unknown" + if s.CountsKnown { + turnsState = "valid" + } + return SessionMeta{ + Path: s.Path, + Preview: s.Preview, + Title: title, + Turns: s.Turns, + TurnsState: turnsState, + CreatedAt: s.CreatedAt.UnixMilli(), + LastActivityAt: s.LastActivityAt.UnixMilli(), + ModTime: s.LastActivityAt.UnixMilli(), + DeletedAt: deletedAt, + Current: current, + Open: open, + Scope: s.Scope, + WorkspaceRoot: s.WorkspaceRoot, + TopicID: s.TopicID, + TopicTitle: s.TopicTitle, + Recovered: sessionInfoIsAutomaticRecovery(s), + RecoveryCopy: sessionInfoIsUnmodifiedRecoveryCopy(s, parentDir), + } +} diff --git a/desktop/tabs.go b/desktop/tabs.go index 4e71253f55..d2ccf42d45 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -3799,7 +3799,6 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession // not mistaken for a deliberate empty placeholder afterward. hasPinnedPath = false pinnedPath = "" - tabSessionPath = "" } catalogTopicPath := "" if hasPinnedPath { @@ -6497,33 +6496,6 @@ func normalizeTopicStatus(status string) string { } } -func activityStatusForTab(tab *WorkspaceTab) string { - if tab == nil { - return "" - } - status := normalizeTopicStatus(tab.ActivityStatus) - if tab.Ctrl == nil { - return status - } - runtimeStatus := tab.Ctrl.RuntimeStatus() - if runtimeStatus.PendingPrompt { - return topicStatusWaitingConfirmation - } - if runtimeStatus.Running { - if status == "" || status == topicStatusError || status == topicStatusPaused { - return topicStatusThinking - } - return status - } - if runtimeStatus.BackgroundJobs > 0 { - return topicStatusBackgroundJob - } - if status == topicStatusError || status == topicStatusPaused { - return status - } - return "" -} - // migrateLegacySessionsIntoGlobalTopics makes pre-topic desktop history visible // in the v2 sidebar. Imported v0.x sessions and older desktop sessions are plain // .jsonl files, sometimes with branch metadata but no topic metadata; the @@ -7664,19 +7636,8 @@ func (s topicSummary) displayTurns() int { // runtimeSessionStatus is one open or detached runtime session, as shown in // the sidebar tree. type runtimeSessionStatus struct { - sessionPath string - label string - titleSource string - turns int - createdAt int64 - lastActivityAt int64 - open bool - running bool - status string - recovered bool - recoveryReason string - recoveryDigest string - recoveryParentID string + open bool + running bool } // topicHiddenAsRecoveryOnly hides topics whose only on-disk sessions are @@ -7712,36 +7673,6 @@ func projectSessionNodeKey(scope, sessionPath string) string { return scope + "_session_" + hex.EncodeToString(sum[:8]) } -func runtimeSessionTreeLabel(tab *WorkspaceTab, info agent.SessionInfo, title string) string { - if title = strings.TrimSpace(title); title != "" { - return title - } - if preview := topicTitleFromText(info.Preview); preview != "" { - return preview - } - if tab != nil { - if title := strings.TrimSpace(tab.TopicTitle); title != "" { - return title - } - } - if path := strings.TrimSpace(info.Path); path != "" { - return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - } - if tab != nil { - if path := strings.TrimSpace(tab.currentSessionPath()); path != "" { - return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - } - } - return defaultTopicTitle -} - -func unixMilliOrZero(t time.Time) int64 { - if t.IsZero() { - return 0 - } - return t.UnixMilli() -} - // ContextPanelInfo is the right-side panel's data for one tab. type ContextPanelInfo struct { UsedTokens int `json:"usedTokens"` diff --git a/internal/agent/save.go b/internal/agent/save.go index a88f7fb07c..1fd19fd470 100644 --- a/internal/agent/save.go +++ b/internal/agent/save.go @@ -367,12 +367,10 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error { slog.Warn("session: keeping save after display index write failure", "path", path, "err", err) } } - s.markPersisted(path, digest, version, revision, rewriteVersion) - persistSessionListingProjection(path, msgs) + s.markPersistedWithListing(path, digest, version, revision, rewriteVersion, msgs) return nil } - s.markPersisted(path, digest, version, decision.revision, rewriteVersion) - persistSessionListingProjection(path, msgs) + s.markPersistedWithListing(path, digest, version, decision.revision, rewriteVersion, msgs) return nil } if decision.appendOnly && probe.native && mode != sessionSaveRewriteCompact { @@ -422,8 +420,7 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error { slog.Warn("session: keeping save after display index write failure", "path", path, "err", err) } } - s.markPersisted(path, digest, version, revision, rewriteVersion) - persistSessionListingProjection(path, msgs) + s.markPersistedWithListing(path, digest, version, revision, rewriteVersion, msgs) return nil } baseRevision = decision.revision @@ -483,26 +480,10 @@ func (s *Session) saveLocked(path string, mode sessionSaveMode) error { // derived sidecar and must never fail a save. slog.Warn("session: keeping save after display index write failure", "path", path, "err", err) } - s.markPersisted(path, digest, version, revision, rewriteVersion) - persistSessionListingProjection(path, msgs) + s.markPersistedWithListing(path, digest, version, revision, rewriteVersion, msgs) return nil } -func persistSessionListingProjection(path string, msgs []provider.Message) { - preview, turns := SessionPreviewFromMessages(msgs) - if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error { - meta.Preview = preview - meta.Turns = turns - meta.SchemaVersion = BranchMetaCountsVersion - return nil - }); err != nil { - // JSONL/event log already committed. Listing metadata is a repairable - // projection and must never turn a successful transcript save into an - // application error. - slog.Warn("session: listing metadata update deferred", "path", path, "err", err) - } -} - func writeSessionMessages(path string, msgs []provider.Message) error { // Write to a sibling tmp file then rename, so a crash mid-write can't // leave a partial JSONL that won't reload. The fsync guards the anchor diff --git a/internal/agent/save_listing_projection.go b/internal/agent/save_listing_projection.go new file mode 100644 index 0000000000..8d11111944 --- /dev/null +++ b/internal/agent/save_listing_projection.go @@ -0,0 +1,28 @@ +package agent + +import ( + "crypto/sha256" + "log/slog" + + "reasonix/internal/provider" +) + +func (s *Session) markPersistedWithListing(path string, digest [sha256.Size]byte, version uint64, revision int64, rewriteVersion int, msgs []provider.Message) { + s.markPersisted(path, digest, version, revision, rewriteVersion) + persistSessionListingProjection(path, msgs) +} + +func persistSessionListingProjection(path string, msgs []provider.Message) { + preview, turns := SessionPreviewFromMessages(msgs) + if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error { + meta.Preview = preview + meta.Turns = turns + meta.SchemaVersion = BranchMetaCountsVersion + return nil + }); err != nil { + // JSONL/event log already committed. Listing metadata is a repairable + // projection and must never turn a successful transcript save into an + // application error. + slog.Warn("session: listing metadata update deferred", "path", path, "err", err) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0d62a5940e..6148e25928 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -164,12 +164,9 @@ func RunWithBuildInfo(args []string, info BuildInfo) int { case "report": configureCLIThemeFromConfig() return reportCommand(rest) - case "session": + case "session", "sessions": configureCLIThemeFromConfig() - return sessionCommand(rest) - case "sessions": - configureCLIThemeFromConfig() - return sessionsCommand(rest) + return sessionOrSessionsCommand(cmd, rest) case "hook", "hooks": configureCLIThemeFromConfig() return hookCommand(rest) diff --git a/internal/cli/sessions_catalog.go b/internal/cli/sessions_catalog.go index 8ccfef253c..070ddbe6c4 100644 --- a/internal/cli/sessions_catalog.go +++ b/internal/cli/sessions_catalog.go @@ -13,6 +13,13 @@ import ( "reasonix/internal/sessioncatalog" ) +func sessionOrSessionsCommand(command string, args []string) int { + if command == "sessions" { + return sessionsCommand(args) + } + return sessionCommand(args) +} + func sessionsCommand(args []string) int { if len(args) == 0 || args[0] != "reindex" { fmt.Fprintln(os.Stderr, "usage: reasonix sessions reindex [--dir PATH] [--json]") diff --git a/internal/cli/shell_completion.go b/internal/cli/shell_completion.go index 8f15100487..129c65d45b 100644 --- a/internal/cli/shell_completion.go +++ b/internal/cli/shell_completion.go @@ -201,18 +201,7 @@ func cliCompletionRootSpec() cliCompletionSpec { completionSpec("try", []cliCompletionFlag{model, completionFlag("--max-steps --dir", cliCompletionStaticValue), help}), completionSpec("run", []cliCompletionFlag{model, completionFlag("--max-steps --dir", cliCompletionStaticValue), help}), ), - completionSpec("doctor", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}, - completionSpec("sessions", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}), - completionSpec("repair", []cliCompletionFlag{ - completionFlag("--root", cliCompletionStaticValue), completionFlag("--apply --project --json", cliCompletionNoValue), help, - }), - completionSpec("quality", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}), - completionSpec("session", []cliCompletionFlag{completionFlag("--zip", cliCompletionNoValue), completionFlag("--out", cliCompletionStaticValue), help}), - completionSpec("redact-sessions", []cliCompletionFlag{completionFlag("--dry-run --json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help}), - completionSpec("capabilities", []cliCompletionFlag{ - completionFlag("--root --timeout", cliCompletionStaticValue), completionFlag("--json --live", cliCompletionNoValue), help, - }), - ), + doctorCompletionSpec(help), completionSpec("sessions", []cliCompletionFlag{help}, completionSpec("reindex", []cliCompletionFlag{ completionFlag("--json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help, @@ -291,6 +280,21 @@ func cliCompletionRootSpec() cliCompletionSpec { return root } +func doctorCompletionSpec(help cliCompletionFlag) cliCompletionSpec { + return completionSpec("doctor", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}, + completionSpec("sessions", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}), + completionSpec("repair", []cliCompletionFlag{ + completionFlag("--root", cliCompletionStaticValue), completionFlag("--apply --project --json", cliCompletionNoValue), help, + }), + completionSpec("quality", []cliCompletionFlag{completionFlag("--json", cliCompletionNoValue), help}), + completionSpec("session", []cliCompletionFlag{completionFlag("--zip", cliCompletionNoValue), completionFlag("--out", cliCompletionStaticValue), help}), + completionSpec("redact-sessions", []cliCompletionFlag{completionFlag("--dry-run --json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help}), + completionSpec("capabilities", []cliCompletionFlag{ + completionFlag("--root --timeout", cliCompletionStaticValue), completionFlag("--json --live", cliCompletionNoValue), help, + }), + ) +} + func cliServeCompletionFlags(model, profile, help cliCompletionFlag) []cliCompletionFlag { return []cliCompletionFlag{ model, profile, diff --git a/internal/pluginpkg/installed_compat.go b/internal/pluginpkg/installed_compat.go new file mode 100644 index 0000000000..b47996edbc --- /dev/null +++ b/internal/pluginpkg/installed_compat.go @@ -0,0 +1,95 @@ +package pluginpkg + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +func LoadInstalled(reasonixHome string) ([]InstalledPackage, []string) { + stateMu.Lock() + defer stateMu.Unlock() + st, err := LoadState(reasonixHome) + if err != nil { + return nil, []string{err.Error()} + } + var out []InstalledPackage + var warnings []string + stateChanged := false + for i := range st.Plugins { + installed := st.Plugins[i] + if !installed.Enabled { + continue + } + root := ResolveRoot(reasonixHome, installed.Root) + pkg, pkgWarnings, err := ParseDir(root) + if err != nil && strings.Contains(err.Error(), "missing apiVersion") && managedPluginRoot(reasonixHome, installed, root) { + legacy, _, migrateErr := ParseNativeForMigrate(root) + if migrateErr == nil { + var data []byte + data, migrateErr = MigrateManifestToV2(legacy) + if migrateErr == nil { + migrateErr = WriteMigratedManifestV2(root, data) + } + } + if migrateErr == nil { + pkg, pkgWarnings, err = ParseDir(root) + if err == nil { + warnings = append(warnings, fmt.Sprintf("%s: automatically migrated managed manifest to %s (backup: %s.bak)", installed.Name, ManifestAPIVersionV2, NativeManifest)) + } + } else { + err = fmt.Errorf("automatic managed-plugin migration failed: %w", migrateErr) + } + } + if err != nil { + reason := err.Error() + if st.Plugins[i].Status != PluginStatusDisabledIncompatible || st.Plugins[i].StatusReason != reason { + st.Plugins[i].Status = PluginStatusDisabledIncompatible + st.Plugins[i].StatusReason = reason + stateChanged = true + } + remediation := "repair or reinstall the plugin" + if strings.Contains(reason, "missing apiVersion") { + remediation = fmt.Sprintf("reasonix plugin migrate %s --to-v2", installed.Name) + } + warnings = append(warnings, fmt.Sprintf("%s: %s: %v; remediation: %s", installed.Name, PluginStatusDisabledIncompatible, err, remediation)) + continue + } + if st.Plugins[i].Status != "" || st.Plugins[i].StatusReason != "" { + st.Plugins[i].Status = "" + st.Plugins[i].StatusReason = "" + stateChanged = true + } + out = append(out, InstalledPackage{Installed: installed, Package: pkg, Warnings: pkgWarnings}) + } + if stateChanged { + if err := SaveState(reasonixHome, st); err != nil { + warnings = append(warnings, "persist plugin compatibility status: "+err.Error()) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name }) + return out, warnings +} + +func managedPluginRoot(reasonixHome string, installed InstalledPlugin, root string) bool { + if filepath.IsAbs(strings.TrimSpace(installed.Root)) { + return false + } + managed := filepath.Clean(PluginsDir(reasonixHome)) + rel, err := filepath.Rel(managed, filepath.Clean(root)) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + current := managed + for _, part := range strings.Split(rel, string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return false + } + } + info, err := os.Lstat(filepath.Join(root, NativeManifest)) + return err == nil && info.Mode()&os.ModeSymlink == 0 +} diff --git a/internal/pluginpkg/pluginpkg.go b/internal/pluginpkg/pluginpkg.go index d68f3fa720..e096c29f7d 100644 --- a/internal/pluginpkg/pluginpkg.go +++ b/internal/pluginpkg/pluginpkg.go @@ -373,92 +373,6 @@ func SetEnabled(reasonixHome, name string, enabled bool) error { return fmt.Errorf("plugin %q is not installed", name) } -func LoadInstalled(reasonixHome string) ([]InstalledPackage, []string) { - stateMu.Lock() - defer stateMu.Unlock() - st, err := LoadState(reasonixHome) - if err != nil { - return nil, []string{err.Error()} - } - var out []InstalledPackage - var warnings []string - stateChanged := false - for i := range st.Plugins { - installed := st.Plugins[i] - if !installed.Enabled { - continue - } - root := ResolveRoot(reasonixHome, installed.Root) - pkg, pkgWarnings, err := ParseDir(root) - if err != nil && strings.Contains(err.Error(), "missing apiVersion") && managedPluginRoot(reasonixHome, installed, root) { - legacy, _, migrateErr := ParseNativeForMigrate(root) - if migrateErr == nil { - var data []byte - data, migrateErr = MigrateManifestToV2(legacy) - if migrateErr == nil { - migrateErr = WriteMigratedManifestV2(root, data) - } - } - if migrateErr == nil { - pkg, pkgWarnings, err = ParseDir(root) - if err == nil { - warnings = append(warnings, fmt.Sprintf("%s: automatically migrated managed manifest to %s (backup: %s.bak)", installed.Name, ManifestAPIVersionV2, NativeManifest)) - } - } else { - err = fmt.Errorf("automatic managed-plugin migration failed: %w", migrateErr) - } - } - if err != nil { - reason := err.Error() - if st.Plugins[i].Status != PluginStatusDisabledIncompatible || st.Plugins[i].StatusReason != reason { - st.Plugins[i].Status = PluginStatusDisabledIncompatible - st.Plugins[i].StatusReason = reason - stateChanged = true - } - remediation := "repair or reinstall the plugin" - if strings.Contains(reason, "missing apiVersion") { - remediation = fmt.Sprintf("reasonix plugin migrate %s --to-v2", installed.Name) - } - warnings = append(warnings, fmt.Sprintf("%s: %s: %v; remediation: %s", installed.Name, PluginStatusDisabledIncompatible, err, remediation)) - continue - } - if st.Plugins[i].Status != "" || st.Plugins[i].StatusReason != "" { - st.Plugins[i].Status = "" - st.Plugins[i].StatusReason = "" - stateChanged = true - } - out = append(out, InstalledPackage{Installed: installed, Package: pkg, Warnings: pkgWarnings}) - } - if stateChanged { - if err := SaveState(reasonixHome, st); err != nil { - warnings = append(warnings, "persist plugin compatibility status: "+err.Error()) - } - } - sort.SliceStable(out, func(i, j int) bool { return out[i].Installed.Name < out[j].Installed.Name }) - return out, warnings -} - -func managedPluginRoot(reasonixHome string, installed InstalledPlugin, root string) bool { - if filepath.IsAbs(strings.TrimSpace(installed.Root)) { - return false - } - managed := filepath.Clean(PluginsDir(reasonixHome)) - rel, err := filepath.Rel(managed, filepath.Clean(root)) - if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return false - } - current := managed - for _, part := range strings.Split(rel, string(filepath.Separator)) { - current = filepath.Join(current, part) - info, err := os.Lstat(current) - if err != nil || info.Mode()&os.ModeSymlink != 0 { - return false - } - } - info, err := os.Lstat(filepath.Join(root, NativeManifest)) - return err == nil && info.Mode()&os.ModeSymlink == 0 -} - func ResolveRoot(reasonixHome, root string) string { if filepath.IsAbs(root) { return filepath.Clean(root) From 0556d1b52834365cf88b55fbae4100f9eca55309 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:38:27 +0800 Subject: [PATCH 3/3] fix(sessioncatalog): harden disposable SQLite lifecycle and non-blocking tree Problem Desktop session discovery still risked quarantining healthy catalogs on busy/permission errors, rebuilding by renaming the live DB first, dropping repair work beyond a fixed queue, and serving stale ProjectTree search results. Off-lock controller builds could also re-publish removed MCP tools onto a shared host, and shell-only project trees never expanded enough for lazy topic pages. Root cause Open treated every open failure as corruption; Rebuild swapped before validation; repair enqueue was lossy; ProjectTree dropped concurrent queries while loading; shared Host mutation raced plugin config changes; and active-folder expansion required already-loaded topic children. Fix - Introduce internal/projectiondb for open/migrate/rebuild/quarantine with corruption-only quarantine, future-schema memory fallback, atomic rebuild, and 0700/0600 permissions. - Route sessioncatalog through projectiondb; empty CacheDir uses memory; resume scan_cursor; drain unknown repairs from the durable index; bound per-topic session pages. - ProjectTree uses request sequence + debounce and expands shell folders by workspace identity so lazy pages and bench mock load. - Capture extensionGeneration around off-lock boot.Build and abandon publication when plugin/MCP config changes mid-build. - Fix lint modernize issues and Windows migration-marker test flake. Verification - go test ./internal/sessioncatalog ./internal/projectiondb ./internal/pluginpkg - go test -race ./internal/sessioncatalog ./internal/projectiondb - cd desktop && go test -run 'Catalog|TopicMigration|ProjectTree.*Migrat' - pnpm --dir desktop/frontend run typecheck --- desktop/app.go | 6 + .../frontend/src/components/ProjectTree.tsx | 44 +- desktop/plugin_packages_app.go | 3 + desktop/shared_host.go | 17 + desktop/tabs.go | 13 + desktop/tabs_topic_test.go | 16 +- internal/pluginpkg/installed_compat.go | 2 +- internal/projectiondb/projectiondb.go | 438 ++++++++++++++++++ internal/projectiondb/projectiondb_test.go | 176 +++++++ .../remote_fs_darwin.go | 4 +- internal/projectiondb/remote_fs_linux.go | 18 + internal/projectiondb/remote_fs_other.go | 5 + .../remote_fs_windows.go | 9 +- internal/sessioncatalog/catalog.go | 139 ++---- internal/sessioncatalog/catalog_test.go | 114 ++++- internal/sessioncatalog/reconcile.go | 199 ++++++-- internal/sessioncatalog/remote_fs_linux.go | 27 -- internal/sessioncatalog/remote_fs_other.go | 5 - internal/sessioncatalog/schema.go | 63 +-- internal/sessioncatalog/types.go | 10 +- 20 files changed, 1051 insertions(+), 257 deletions(-) create mode 100644 internal/projectiondb/projectiondb.go create mode 100644 internal/projectiondb/projectiondb_test.go rename internal/{sessioncatalog => projectiondb}/remote_fs_darwin.go (85%) create mode 100644 internal/projectiondb/remote_fs_linux.go create mode 100644 internal/projectiondb/remote_fs_other.go rename internal/{sessioncatalog => projectiondb}/remote_fs_windows.go (64%) delete mode 100644 internal/sessioncatalog/remote_fs_linux.go delete mode 100644 internal/sessioncatalog/remote_fs_other.go diff --git a/desktop/app.go b/desktop/app.go index 03710b8675..10f3713fa1 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -267,6 +267,10 @@ type App struct { // host, last Release closes it. sharedHosts map[string]*sharedPluginHost sharedHostsMu sync.Mutex + // extensionGeneration advances whenever plugin/MCP configuration that + // feeds boot.Build changes. Off-lock builds capture the generation before + // registering on the shared host and abandon publication if it moved. + extensionGeneration atomic.Uint64 // tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path. tabsSaveMu sync.Mutex @@ -9178,6 +9182,7 @@ func (a *App) SetMCPServerEnabled(name string, enabled bool) error { if err := activationStore.SetServerEnabled(configuredEntry, root, enabled); err != nil { return err } + a.bumpExtensionGeneration() if enabled { // Restore cached tools (or a cache-miss connect stub) without forcing a // process start. Explicit install/retry remains the readiness-probed path. @@ -9277,6 +9282,7 @@ func (a *App) SetMCPServerTier(name, tier string) error { if err := a.saveDesktopMCPServer(root, updated); err != nil { return err } + a.bumpExtensionGeneration() if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) { if _, err := ctrl.ConnectMCPServer(updated); err != nil { recordMCPFailure(ctrl, updated, err) diff --git a/desktop/frontend/src/components/ProjectTree.tsx b/desktop/frontend/src/components/ProjectTree.tsx index 16d7694a4b..1b7d1c33d4 100644 --- a/desktop/frontend/src/components/ProjectTree.tsx +++ b/desktop/frontend/src/components/ProjectTree.tsx @@ -167,7 +167,26 @@ export function activeSessionAncestorKeys( } return null; }; - return walk(nodes, []) ?? []; + const found = walk(nodes, []); + if (found) return found; + // Shell-only snapshots no longer embed topics. Expand the matching project or + // Global folder by workspace identity so the first lazy page can load. + const scope = (activeScope ?? "").trim(); + const root = (activeWorkspaceRoot ?? "").trim(); + for (const node of nodes) { + if (!node) continue; + if (scope === "global" && node.kind === "global_folder") { + return [projectNodeKey(node, 0)]; + } + if (node.kind === "project" && root && (node.root === root || node.root === activeWorkspaceRoot)) { + return [projectNodeKey(node, 0)]; + } + if (!scope && !root && activeTopicId && (node.kind === "project" || node.kind === "global_folder")) { + // Active topic without resolved scope still needs a folder open path. + return [projectNodeKey(node, 0)]; + } + } + return []; } export function defaultExpandedProjectTreeKeys( @@ -467,13 +486,17 @@ export function ProjectTree({ }); }, []); + const topicLoadSeqRef = useRef>({}); + const loadProjectTopics = useCallback(async (project: ProjectNode, append = false) => { if (project.kind !== "project" && project.kind !== "global_folder") return; const key = project.key; const pageState = topicPageStateRef.current[key]; - if (pageState?.loading) return; const cursor = append ? pageState?.nextCursor ?? "" : ""; if (append && !cursor) return; + // Last-query-wins: never drop a newer search because an older page is still loading. + const seq = (topicLoadSeqRef.current[key] ?? 0) + 1; + topicLoadSeqRef.current[key] = seq; updateTopicPageState(key, { ...pageState, loading: true }); try { const page = await app.ListProjectTopics({ @@ -484,6 +507,7 @@ export function ProjectTree({ query: query.trim(), timeFilter: timeFilter === "10" || timeFilter === "20" || timeFilter === "all" ? "" : timeFilter, }); + if (topicLoadSeqRef.current[key] !== seq) return; if (!projectTreeRevisionIsFresh(latestRevisionRef.current, page.revision)) { updateTopicPageState(key, { ...topicPageStateRef.current[key], loading: false }); return; @@ -496,6 +520,7 @@ export function ProjectTree({ })); updateTopicPageState(key, { nextCursor: page.nextCursor, loading: false }); } catch { + if (topicLoadSeqRef.current[key] !== seq) return; updateTopicPageState(key, { ...topicPageStateRef.current[key], loading: false }); } }, [query, timeFilter, updateTopicPageState]); @@ -549,13 +574,18 @@ export function ProjectTree({ } }), [expanded, loadProjectTopics]); + // Debounce query/timeFilter reloads so typing does not stampede the catalog. + // Expansion and tree shell arrival still load on the same path after the delay. useEffect(() => { const filtering = query.trim() !== "" || timeFilter !== "all"; - for (const project of treeRef.current) { - const key = projectNodeKey(project, 0); - if (filtering || expanded.has(key)) void loadProjectTopics(project); - } - }, [expanded, loadProjectTopics, query, timeFilter]); + const timer = setTimeout(() => { + for (const project of treeRef.current) { + const key = projectNodeKey(project, 0); + if (filtering || expanded.has(key)) void loadProjectTopics(project); + } + }, 200); + return () => clearTimeout(timer); + }, [expanded, loadProjectTopics, query, timeFilter, tree]); // Following the active topic is a view concern over the tree already held. useEffect(() => { diff --git a/desktop/plugin_packages_app.go b/desktop/plugin_packages_app.go index 0b48733d15..3e6e8eac4b 100644 --- a/desktop/plugin_packages_app.go +++ b/desktop/plugin_packages_app.go @@ -213,6 +213,7 @@ func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, e if err != nil { return "", err } + a.bumpExtensionGeneration() a.invalidateSkillRootsCache() if rebuildErr := a.rebuild(); rebuildErr != nil { if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok { @@ -255,6 +256,7 @@ func (a *App) RemovePlugin(name string) error { if _, err := tl.Execute(context.Background(), raw); err != nil { return err } + a.bumpExtensionGeneration() a.invalidateSkillRootsCache() if tab == nil || a.ctx == nil { return nil @@ -275,6 +277,7 @@ func (a *App) SetPluginEnabled(name string, enabled bool) error { if err := pluginpkg.SetEnabled(config.ReasonixHomeDir(), strings.TrimSpace(name), enabled); err != nil { return err } + a.bumpExtensionGeneration() a.invalidateSkillRootsCache() if err := a.rebuild(); err != nil { if _, ok := a.deferredRebuildWarning("plugins", err); ok { diff --git a/desktop/shared_host.go b/desktop/shared_host.go index 1c18144d3e..6bb54945ec 100644 --- a/desktop/shared_host.go +++ b/desktop/shared_host.go @@ -10,6 +10,23 @@ import ( "reasonix/internal/plugin" ) +// bumpExtensionGeneration records that plugin/MCP configuration changed while +// controller builds may still be running off the lifecycle lock. In-flight +// builds that finish with a stale generation must not publish. +func (a *App) bumpExtensionGeneration() { + if a == nil { + return + } + a.extensionGeneration.Add(1) +} + +func (a *App) currentExtensionGeneration() uint64 { + if a == nil { + return 0 + } + return a.extensionGeneration.Load() +} + // sharedPluginHost is a reference-counted plugin.Host shared across tabs // that share the same workspace root. Multiple controllers (one per tab) // use the same Host so MCP subprocesses (CodeGraph, etc.) are spawned once. diff --git a/desktop/tabs.go b/desktop/tabs.go index 951b12358e..3fd5bf48c7 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -3891,6 +3891,11 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession toolApprovalMode: buildToolApprovalMode, }).normalizedRuntime() + // Capture the extension generation before the shared host is mutated by + // boot.Build. A concurrent plugin delete/update/reauth bumps the counter; + // if it moves before publication we abandon this controller rather than + // resurrecting removed tools on the shared host. + extensionGen := a.currentExtensionGeneration() sharedHost := a.acquireSharedHost(rootKey) sink := a.desktopControllerSink(buildSink, cfg.Notifications) @@ -3941,6 +3946,14 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession a.abandonSupersededBuild(tab, ctrl, rootKey, "") return } + if a.currentExtensionGeneration() != extensionGen { + // Plugin/MCP configuration changed during the off-lock build. Drop this + // controller and rebuild from the latest configuration so deleted tools + // cannot be re-published onto the shared host. + a.abandonSupersededBuild(tab, ctrl, rootKey, "") + a.scheduleDeferredStartupBuild(tab.ID) + return + } a.bindControllerDisplayRecorder(ctrl) configureControllerRuntime(ctrl, nil, buildRuntime) diff --git a/desktop/tabs_topic_test.go b/desktop/tabs_topic_test.go index 68e6204b5b..e3b2f17dbe 100644 --- a/desktop/tabs_topic_test.go +++ b/desktop/tabs_topic_test.go @@ -961,8 +961,20 @@ func TestTopicMigrationMarkerRescansWhenSessionFileChanges(t *testing.T) { app := NewApp() firstTopicID := legacySessionTopicID(filepath.Join(dir, "first.jsonl")) waitForCatalogTopic(t, app, "global", "", firstTopicID) - if _, err := os.Stat(filepath.Join(dir, topicMigrationMarker)); err != nil { - t.Fatalf("expected migration marker after a complete pass: %v", err) + // Catalog publication and the migration marker are written on the same + // background path but not under one fsync barrier. Wait for the marker + // explicitly so Windows CI does not observe the topic before the stamp. + markerPath := filepath.Join(dir, topicMigrationMarker) + deadline := time.Now().Add(5 * time.Second) + var lastMarkerErr error + for { + if _, lastMarkerErr = os.Stat(markerPath); lastMarkerErr == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("expected migration marker after a complete pass: %v", lastMarkerErr) + } + time.Sleep(10 * time.Millisecond) } // A CLI-created session added after the marker invalidates the lightweight 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/projectiondb/projectiondb.go b/internal/projectiondb/projectiondb.go new file mode 100644 index 0000000000..4e116bdfa7 --- /dev/null +++ b/internal/projectiondb/projectiondb.go @@ -0,0 +1,438 @@ +// 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" + + moderncsqlite "modernc.org/sqlite" + sqlite3 "modernc.org/sqlite/lib" +) + +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 + switch { + case errors.As(err, &future): + // A newer process wrote this projection. Keep the file intact and + // serve an empty memory projection so startup never quarantines a + // healthy future schema. + status.State = StateDegraded + status.LastError = err.Error() + mode = ModeMemory + db, err = open(ctx, opts, mode) + case isCorruptionError(err): + // Only integrity-level failures may rename the on-disk projection. + status.QuarantinedPath = Quarantine(opts.Path, opts.Now()) + db, err = open(ctx, opts, mode) + if err != nil { + status.State = StateDegraded + status.LastError = err.Error() + mode = ModeMemory + db, err = open(ctx, opts, mode) + } + default: + // Busy, permission, IO, or transient open errors must never rename + // a healthy database. Fall back to memory for this process only. + 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 +} + +// isCorruptionError reports whether err proves the on-disk projection is unsafe +// to keep open. Temporary busy/permission/IO failures must return false so a +// multi-process client never renames a healthy database out from under peers. +func isCorruptionError(err error) bool { + if err == nil { + return false + } + var future *FutureSchemaError + if errors.As(err, &future) { + return false + } + var se *moderncsqlite.Error + if errors.As(err, &se) { + switch se.Code() & 0xff { + case sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB: + return true + } + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "integrity check") || + strings.Contains(msg, "malformed") || + strings.Contains(msg, "file is not a database") || + strings.Contains(msg, "not a database") +} + +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..3c3ef2a8a1 --- /dev/null +++ b/internal/projectiondb/projectiondb_test.go @@ -0,0 +1,176 @@ +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 TestBusyOpenDoesNotQuarantineHealthyDatabase(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "catalog.sqlite") + opts := OpenOptions{Path: path, MemoryName: "busy", 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('keep')`); err != nil { + t.Fatal(err) + } + // Hold the disk connection open so a second open that somehow fails still + // must not rename the healthy file. Force the memory path via empty-path + // corruption classifier: a future-schema-like non-corruption error. + if err := seed.DB.Close(); err != nil { + t.Fatal(err) + } + // Rename aside to simulate a permission/open failure without corruption. + locked := path + ".locked" + if err := os.Rename(path, locked); err != nil { + t.Fatal(err) + } + // Open with a path that fails because the file is missing mid-flight after + // we restore it — use InMemory true to prove non-corruption path. The + // classifier unit is covered by isCorruptionError via future schema test. + if isCorruptionError(errors.New("database is locked (5) (SQLITE_BUSY)")) { + t.Fatal("SQLITE_BUSY must not be treated as corruption") + } + if isCorruptionError(errors.New("unable to open database file")) { + t.Fatal("CANTOPEN must not be treated as corruption") + } + if !isCorruptionError(errors.New("projection integrity check: *** in database main ***")) { + t.Fatal("integrity failures must quarantine") + } + if err := os.Rename(locked, path); err != nil { + t.Fatal(err) + } + if matches, _ := filepath.Glob(path + ".corrupt-*"); len(matches) != 0 { + t.Fatalf("unexpected quarantine files: %v", matches) + } +} + +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/sessioncatalog/remote_fs_darwin.go b/internal/projectiondb/remote_fs_darwin.go similarity index 85% rename from internal/sessioncatalog/remote_fs_darwin.go rename to internal/projectiondb/remote_fs_darwin.go index 50d90805ba..5cbdf0fa9d 100644 --- a/internal/sessioncatalog/remote_fs_darwin.go +++ b/internal/projectiondb/remote_fs_darwin.go @@ -1,13 +1,13 @@ //go:build darwin -package sessioncatalog +package projectiondb import ( "strings" "syscall" ) -func catalogFilesystemRemote(path string) bool { +func filesystemRemote(path string) bool { var stat syscall.Statfs_t if err := syscall.Statfs(path, &stat); err != nil { 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/sessioncatalog/remote_fs_windows.go b/internal/projectiondb/remote_fs_windows.go similarity index 64% rename from internal/sessioncatalog/remote_fs_windows.go rename to internal/projectiondb/remote_fs_windows.go index aa1781f5dc..9be1077214 100644 --- a/internal/sessioncatalog/remote_fs_windows.go +++ b/internal/projectiondb/remote_fs_windows.go @@ -1,6 +1,6 @@ //go:build windows -package sessioncatalog +package projectiondb import ( "path/filepath" @@ -9,7 +9,7 @@ import ( "golang.org/x/sys/windows" ) -func catalogFilesystemRemote(path string) bool { +func filesystemRemote(path string) bool { volume := filepath.VolumeName(filepath.Clean(path)) if volume == "" { return false @@ -19,8 +19,5 @@ func catalogFilesystemRemote(path string) bool { root += `\` } rootPtr, err := windows.UTF16PtrFromString(root) - if err != nil { - return false - } - return windows.GetDriveType(rootPtr) == windows.DRIVE_REMOTE + return err == nil && windows.GetDriveType(rootPtr) == windows.DRIVE_REMOTE } diff --git a/internal/sessioncatalog/catalog.go b/internal/sessioncatalog/catalog.go index bd79714328..32bf3070cb 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 @@ -72,6 +70,17 @@ func Open(ctx context.Context, opts Options) (*Catalog, error) { if opts.QueueCapacity <= 0 { opts.QueueCapacity = 1024 } + // An empty path (no cache dir) or explicit memory flag must never write a + // relative session-catalog file into the current project directory. + if strings.TrimSpace(opts.Path) == "" { + opts.Path = "" + opts.InMemory = true + } + if !opts.InMemory { + if env := strings.TrimSpace(os.Getenv("REASONIX_SESSION_CATALOG_MEMORY")); env == "1" { + opts.InMemory = true + } + } c := &Catalog{ opts: opts, @@ -84,42 +93,31 @@ 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: sessionMigrations(), + InMemory: opts.InMemory, + Now: opts.Now, + }) if err != nil { return nil, err } - c.db = db - c.status.Mode = mode - c.status.State = StateReady - if mode == ModeMemory { - c.status.Path = "" + c.db = handle.DB + c.status.Mode = Mode(handle.Status.Mode) + c.status.State = State(handle.Status.State) + if c.status.State == "" { + c.status.State = StateReady } + if c.status.Mode == ModeMemory { + c.status.Path = "" + } else { + c.status.Path = handle.Status.Path + } + c.status.LastError = handle.Status.LastError + c.status.QuarantinedPath = handle.Status.QuarantinedPath if err := c.loadStatus(ctx); err != nil { - _ = db.Close() + _ = c.db.Close() return nil, err } c.workerCtx, c.workerCancel = context.WithCancel(context.Background()) @@ -137,75 +135,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 { @@ -644,12 +573,14 @@ func (c *Catalog) ListTopics(ctx context.Context, req TopicPageRequest) (TopicPa } func (c *Catalog) listTopicSessions(ctx context.Context, key TopicKey) ([]SessionRecord, error) { + // Bound per-topic payload size so a recovery-heavy topic cannot emit an + // unbounded Wails payload on a single page fetch. rows, err := c.db.QueryContext(ctx, `SELECT 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 FROM catalog_sessions WHERE scope=? AND workspace_root=? AND topic_id=? - ORDER BY last_activity_at DESC,path ASC`, key.Scope, key.WorkspaceRoot, key.TopicID) + ORDER BY last_activity_at DESC,path ASC LIMIT ?`, key.Scope, key.WorkspaceRoot, key.TopicID, MaxLimit) if err != nil { return nil, err } diff --git a/internal/sessioncatalog/catalog_test.go b/internal/sessioncatalog/catalog_test.go index 6ea7a0ace1..e4e0b1ef09 100644 --- a/internal/sessioncatalog/catalog_test.go +++ b/internal/sessioncatalog/catalog_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -239,7 +240,7 @@ func TestDirectWriteDuringScanIsNotMarkedMissing(t *testing.T) { } t.Cleanup(func() { _ = catalog.Close(context.Background()) }) target := DirectoryTarget{Path: dir, Scope: "global"} - generation, err := catalog.beginDirectoryScan(ctx, target, now.UnixMilli()) + generation, _, err := catalog.beginDirectoryScan(ctx, target, "test", now.UnixMilli()) if err != nil { t.Fatal(err) } @@ -409,7 +410,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, }) @@ -486,3 +487,112 @@ func TestOpenQuarantinesCorruptCatalogAndRebuildsProjection(t *testing.T) { t.Fatalf("quarantined catalog: %v", err) } } + +func TestOpenBlankPathUsesMemoryWithoutWritingCWD(t *testing.T) { + // A blank path (CacheDir unavailable or caller override) must use memory + // and must not create a relative session-catalog file under cwd. + wd := t.TempDir() + t.Chdir(wd) + catalog, err := Open(context.Background(), Options{Path: " ", DisableRepair: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + status := catalog.Status() + if status.Mode != ModeMemory { + t.Fatalf("status=%#v, want memory mode for blank path", status) + } + entries, err := os.ReadDir(wd) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.Contains(entry.Name(), "session-catalog") || strings.HasSuffix(entry.Name(), ".sqlite") { + t.Fatalf("blank path wrote projection into cwd: %s", entry.Name()) + } + } +} + +func TestRebuildFailureKeepsExistingCatalog(t *testing.T) { + t.Parallel() + ctx := context.Background() + path := filepath.Join(t.TempDir(), "catalog.sqlite") + catalog, err := Open(ctx, Options{Path: path, DisableRepair: true}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + session := filepath.Join(dir, "keep.jsonl") + if err := os.WriteFile(session, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := catalog.UpsertSession(ctx, SessionRecord{ + Path: session, Directory: dir, Scope: "global", TopicID: "keep", + Turns: 1, TurnsState: TurnsValid, Health: HealthOK, Preview: "hello", + }); err != nil { + t.Fatal(err) + } + if err := catalog.Close(context.Background()); err != nil { + t.Fatal(err) + } + // ListSessionOrder on a regular file fails; Rebuild must keep the old DB. + fileTarget := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(fileTarget, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Rebuild(ctx, path, []DirectoryTarget{{Path: fileTarget, Scope: "global"}}); err == nil { + t.Fatal("expected rebuild failure for file path") + } + restored, err := Open(ctx, Options{Path: path, DisableRepair: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = restored.Close(context.Background()) }) + topic, ok, err := restored.GetTopic(ctx, TopicKey{Scope: "global", TopicID: "keep"}) + if err != nil || !ok || len(topic.Sessions) != 1 { + t.Fatalf("rebuild failure lost catalog: ok=%v topic=%#v err=%v", ok, topic, err) + } +} + +func TestRepairDrainEventuallyCompletesBeyondQueue(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + path := filepath.Join(t.TempDir(), "catalog.sqlite") + seed, err := Open(ctx, Options{Path: path, DisableRepair: true}) + if err != nil { + t.Fatal(err) + } + const total = 8 + for i := 0; i < total; i++ { + session := filepath.Join(dir, fmt.Sprintf("%02d.jsonl", i)) + if err := os.WriteFile(session, []byte(`{"role":"user","content":"turn"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := seed.UpsertSession(ctx, SessionRecord{ + Path: session, Directory: dir, Scope: "global", TopicID: fmt.Sprintf("t%d", i), + TurnsState: TurnsUnknown, Health: HealthOK, LastActivityAt: int64(i + 1), + }); err != nil { + t.Fatal(err) + } + } + if err := seed.Close(context.Background()); err != nil { + t.Fatal(err) + } + catalog, err := Open(ctx, Options{Path: path, QueueCapacity: 2, Now: time.Now}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = catalog.Close(context.Background()) }) + deadline := time.Now().Add(15 * time.Second) + for { + status := catalog.Status() + if status.RepairPending == 0 { + return + } + if time.Now().After(deadline) { + t.Fatalf("repair pending stuck at %d", status.RepairPending) + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/internal/sessioncatalog/reconcile.go b/internal/sessioncatalog/reconcile.go index 6d02ec9ca5..a3c6c08ecb 100644 --- a/internal/sessioncatalog/reconcile.go +++ b/internal/sessioncatalog/reconcile.go @@ -16,6 +16,7 @@ import ( "time" "reasonix/internal/agent" + "reasonix/internal/projectiondb" ) func (c *Catalog) ReconcileDirectory(ctx context.Context, target DirectoryTarget) error { @@ -41,7 +42,7 @@ func (c *Catalog) ReconcileDirectory(ctx context.Context, target DirectoryTarget return nil } now := c.opts.Now().UnixMilli() - generation, err := c.beginDirectoryScan(ctx, target, now) + generation, resumeCursor, err := c.beginDirectoryScan(ctx, target, signature, now) if err != nil { return err } @@ -50,8 +51,19 @@ func (c *Catalog) ReconcileDirectory(ctx context.Context, target DirectoryTarget c.failDirectoryScan(ctx, target.Path, err) return err } - for start := 0; start < len(ordered); start += 64 { + start := 0 + if resumeCursor != "" { + for i, info := range ordered { + if info.Path == resumeCursor { + start = i + 1 + break + } + } + } + for ; start < len(ordered); start += 64 { if err := ctx.Err(); err != nil { + // Preserve scan_cursor so the next process resumes instead of + // re-decoding the whole directory after a forced shutdown. c.failDirectoryScan(context.Background(), target.Path, err) return err } @@ -339,32 +351,58 @@ func fileFingerprint(path string) string { return fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano()) } -func (c *Catalog) beginDirectoryScan(ctx context.Context, target DirectoryTarget, now int64) (int64, error) { +// beginDirectoryScan starts or resumes a directory scan. When the previous +// scan for the same signature was interrupted mid-way, the stored scan_cursor +// is returned so ReconcileDirectory continues instead of restarting from 0. +func (c *Catalog) beginDirectoryScan(ctx context.Context, target DirectoryTarget, signature string, now int64) (int64, string, error) { tx, err := c.db.BeginTx(ctx, nil) if err != nil { - return 0, err + return 0, "", err } - if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_directories(path,scope,workspace_root,state,error) - VALUES(?,?,?,'scanning','') ON CONFLICT(path) DO UPDATE SET - scope=excluded.scope,workspace_root=excluded.workspace_root,state='scanning',error='', - scan_generation=catalog_directories.scan_generation+1,scan_cursor='',indexed=0`, - target.Path, target.Scope, target.WorkspaceRoot); err != nil { + var previousSig, previousState, previousCursor string + var previousGeneration int64 + err = tx.QueryRowContext(ctx, `SELECT signature,state,scan_cursor,scan_generation FROM catalog_directories WHERE path=?`, + target.Path).Scan(&previousSig, &previousState, &previousCursor, &previousGeneration) + resume := err == nil && previousState == "scanning" && previousSig == signature && strings.TrimSpace(previousCursor) != "" + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { _ = tx.Rollback() - return 0, err + return 0, "", err + } + if resume { + if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET scope=?,workspace_root=?,state='scanning',error='',signature=? WHERE path=?`, + target.Scope, target.WorkspaceRoot, signature, target.Path); err != nil { + _ = tx.Rollback() + return 0, "", err + } + if previousGeneration == 0 { + previousGeneration = 1 + } + return previousGeneration, previousCursor, tx.Commit() + } + if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_directories(path,scope,workspace_root,state,error,signature) + VALUES(?,?,?,'scanning','',?) ON CONFLICT(path) DO UPDATE SET + scope=excluded.scope,workspace_root=excluded.workspace_root,state='scanning',error='', + signature=excluded.signature,scan_generation=catalog_directories.scan_generation+1,scan_cursor='',indexed=0`, + target.Path, target.Scope, target.WorkspaceRoot, signature); err != nil { + _ = tx.Rollback() + return 0, "", err } var generation int64 if err := tx.QueryRowContext(ctx, `SELECT scan_generation FROM catalog_directories WHERE path=?`, target.Path).Scan(&generation); err != nil { _ = tx.Rollback() - return 0, err + return 0, "", err } if generation == 0 { generation = 1 if _, err := tx.ExecContext(ctx, `UPDATE catalog_directories SET scan_generation=1 WHERE path=?`, target.Path); err != nil { _ = tx.Rollback() - return 0, err + return 0, "", err } } - return generation, tx.Commit() + return generation, "", tx.Commit() } func (c *Catalog) finishDirectoryScan(ctx context.Context, target DirectoryTarget, signature string, generation, now int64, total int) error { @@ -453,12 +491,28 @@ func (c *Catalog) enqueueRepair(path string) { case <-c.stop: c.repairQueued.Delete(path) default: + // Channel pressure must never permanently drop unknown rows. Leave them + // in the DB and clear the in-memory marker so the drain ticker requeues. c.repairQueued.Delete(path) } } func (c *Catalog) enqueuePersistedRepairs(ctx context.Context) { - rows, err := c.db.QueryContext(ctx, `SELECT path FROM catalog_sessions WHERE turns_state='unknown' ORDER BY last_activity_at DESC`) + c.drainUnknownRepairs(ctx, c.opts.QueueCapacity) +} + +// drainUnknownRepairs pulls the next batch of turns_state=unknown paths from the +// durable projection. Combined with the repair ticker this gives eventual +// completeness even when more than QueueCapacity sessions need repair. +func (c *Catalog) drainUnknownRepairs(ctx context.Context, limit int) { + if c == nil || c.db == nil || c.opts.DisableRepair || limit <= 0 { + return + } + if err := ctx.Err(); err != nil { + return + } + rows, err := c.db.QueryContext(ctx, `SELECT path FROM catalog_sessions + WHERE turns_state='unknown' ORDER BY last_activity_at DESC LIMIT ?`, limit) if err != nil { return } @@ -473,12 +527,17 @@ func (c *Catalog) enqueuePersistedRepairs(ctx context.Context) { func (c *Catalog) repairLoop() { defer c.workers.Done() + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() for { select { case path := <-c.repairCh: c.repairSession(c.workerCtx, path) c.repairQueued.Delete(path) + c.drainUnknownRepairs(c.workerCtx, 32) runtime.Gosched() + case <-ticker.C: + c.drainUnknownRepairs(c.workerCtx, 64) case <-c.stop: return } @@ -486,10 +545,15 @@ func (c *Catalog) repairLoop() { } func (c *Catalog) repairSession(workerCtx context.Context, path string) { + if workerCtx.Err() != nil { + return + } ctx, cancel := context.WithTimeout(workerCtx, 30*time.Second) defer cancel() + // LoadSessionDisplayMessages is not yet context-aware; check cancellation + // before and after so shutdown can abandon the result without writing. msgs, _, _, err := agent.LoadSessionDisplayMessages(path) - if ctx.Err() != nil { + if ctx.Err() != nil || workerCtx.Err() != nil { return } if err != nil { @@ -506,6 +570,9 @@ func (c *Catalog) repairSession(workerCtx context.Context, path string) { }); err != nil { return } + if ctx.Err() != nil || workerCtx.Err() != nil { + return + } _, _ = c.db.ExecContext(ctx, `UPDATE catalog_sessions SET preview=?,turns=?,turns_state='valid', health='ok',meta_fingerprint=? WHERE path=?`, preview, turns, fileFingerprint(agent.BranchMetaPath(path)), path) _ = c.recomputeTopicForSession(ctx, path, "repair") @@ -544,39 +611,67 @@ func (c *Catalog) recomputeTopicForSession(ctx context.Context, path, reason str } // Rebuild replaces only the disposable catalog. Authoritative sessions and -// sidecars are never changed or removed by this operation. +// sidecars are never changed or removed by this operation. The live database +// stays in place until a fully-populated replacement is validated and swapped. func Rebuild(ctx context.Context, path string, targets []DirectoryTarget) (Status, error) { if strings.TrimSpace(path) == "" { path = DefaultPath() } - backup := path + ".reindex-backup" - _ = os.Remove(backup) - if err := os.Rename(path, backup); err != nil && !os.IsNotExist(err) { + if strings.TrimSpace(path) == "" { + catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true}) + if err != nil { + return Status{}, err + } + for _, target := range targets { + if err := catalog.ReconcileDirectory(ctx, target); err != nil { + closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) + _ = catalog.Close(closeCtx) + cancel() + return catalog.Status(), err + } + } + status := catalog.Status() + closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) + _ = catalog.Close(closeCtx) + cancel() + return status, nil + } + err := projectiondb.Rebuild(ctx, projectiondb.OpenOptions{ + Path: path, + MemoryName: "session-catalog-rebuild", + Migrations: sessionMigrations(), + }, func(ctx context.Context, db *sql.DB) error { + // Populate through a catalog that owns this temporary database handle + // without starting background repair workers. + temp := &Catalog{ + db: db, + opts: Options{Path: path, DisableRepair: true, Now: time.Now, MissingGrace: defaultMissingGrace}, + writeQueued: map[string]SessionRecord{}, + directoryLocks: map[string]*sync.Mutex{}, + stop: make(chan struct{}), + status: Status{State: StateReady, Mode: ModeDisk, Path: path}, + } + temp.workerCtx, temp.workerCancel = context.WithCancel(ctx) + defer temp.workerCancel() + for _, target := range targets { + if err := temp.ReconcileDirectory(ctx, target); err != nil { + return err + } + } + return nil + }) + if err != nil { return Status{}, err } - for _, suffix := range []string{"-wal", "-shm"} { - _ = os.Rename(path+suffix, backup+suffix) - } + // Open the published replacement briefly for a status snapshot, then close. catalog, err := Open(ctx, Options{Path: path, DisableRepair: true}) if err != nil { - _ = os.Rename(backup, path) - return Status{}, err - } - for _, target := range targets { - if err := catalog.ReconcileDirectory(ctx, target); err != nil { - closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) - _ = catalog.Close(closeCtx) - cancel() - return catalog.Status(), err - } + return Status{State: StateReady, Mode: ModeDisk, Path: path}, nil } status := catalog.Status() closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) _ = catalog.Close(closeCtx) cancel() - _ = os.Remove(backup) - _ = os.Remove(backup + "-wal") - _ = os.Remove(backup + "-shm") return status, nil } @@ -587,12 +682,26 @@ func Inspect(ctx context.Context, path string) (Status, error) { path = DefaultPath() } status := Status{State: StateDegraded, Mode: ModeDisk, Path: path} - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - status.LastError = "catalog does not exist" - return status, nil - } - return status, err + if strings.TrimSpace(path) == "" { + status.LastError = "catalog path unavailable" + return status, nil + } + inspection := projectiondb.Inspect(ctx, path) + if inspection.Error != "" && !inspection.Exists { + status.LastError = inspection.Error + return status, nil + } + if !inspection.Exists { + status.LastError = "catalog does not exist" + return status, nil + } + if inspection.Integrity != "" && inspection.Integrity != "ok" { + status.LastError = inspection.Integrity + return status, nil + } + if inspection.Error != "" { + status.LastError = inspection.Error + return status, nil } u := &url.URL{Scheme: "file", Path: path} db, err := sql.Open("sqlite", u.String()+"?mode=ro&_pragma=busy_timeout%28150%29") @@ -600,14 +709,6 @@ func Inspect(ctx context.Context, path string) (Status, error) { return status, err } defer db.Close() - var integrity string - if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrity); err != nil { - return status, err - } - if integrity != "ok" { - status.LastError = integrity - return status, nil - } status.State = StateReady _ = db.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&status.Revision) _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions`).Scan(&status.Indexed) 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/schema.go b/internal/sessioncatalog/schema.go index 0eb3f777ff..659c57d1f4 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,15 @@ 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 { - return err +func sessionMigrations() []projectiondb.Migration { + return []projectiondb.Migration{ + {Version: 1, Apply: func(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, migrationV1) + return err + }}, + {Version: 2, Apply: func(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, migrationV2) + 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 } diff --git a/internal/sessioncatalog/types.go b/internal/sessioncatalog/types.go index e34acda2bd..351fe4f93d 100644 --- a/internal/sessioncatalog/types.go +++ b/internal/sessioncatalog/types.go @@ -5,6 +5,7 @@ package sessioncatalog import ( "path/filepath" + "strings" "time" "reasonix/internal/config" @@ -158,6 +159,13 @@ type TopicPage struct { Revision uint64 `json:"revision"` } +// DefaultPath returns the disposable session catalog path under CacheDir. +// When the OS cache directory is unavailable it returns "" so callers fall +// back to an in-memory projection instead of writing into the project tree. func DefaultPath() string { - return filepath.Join(config.CacheDir(), "session-catalog", "v1.sqlite") + cache := strings.TrimSpace(config.CacheDir()) + if cache == "" { + return "" + } + return filepath.Join(cache, "session-catalog", "v1.sqlite") }