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 OR (pinned=? AND last_activity_at) OR (pinned=? AND last_activity_at=? AND topic_id>?))`
+ 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 AND topic_id<>''`, 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_generation`, now, target.Path, generation); err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ cutoff := now - c.opts.MissingGrace.Milliseconds()
+ if _, err := tx.ExecContext(ctx, `DELETE FROM catalog_sessions
+ WHERE directory=? AND seen_generation AND missing_since>0 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")
+}